diff --git a/.github/ISSUE_TEMPLATE/task-that-belongs-to-a-story-epic.md b/.github/ISSUE_TEMPLATE/task-that-belongs-to-a-story-epic.md
new file mode 100644
index 0000000000..fe49564b9d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/task-that-belongs-to-a-story-epic.md
@@ -0,0 +1,11 @@
+---
+name: Task that belongs to a story/epic
+about: A skeleton task where the details are all contained within a story or epic
+ on element-meta.
+title: "[Task] "
+labels: T-Task
+assignees: ''
+
+---
+
+Please see and discuss the details in the meta issue.
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index 56f5f1219b..3780c7a87d 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -32,7 +32,7 @@ jobs:
mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages
- uses: peaceiris/actions-gh-pages@v3
+ uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 8d81632f83..fe63bb677d 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml
index dcb5cd3223..69833a985e 100644
--- a/.maestro/tests/account/login.yaml
+++ b/.maestro/tests/account/login.yaml
@@ -23,7 +23,8 @@ appId: ${MAESTRO_APP_ID}
- inputText: ${MAESTRO_PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
+- runFlow: ../assertions/assertSessionVerificationDisplayed.yaml
+- runFlow: ./verifySession.yaml
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml
-- runFlow: ./verifySession.yaml
diff --git a/.maestro/tests/account/verifySession.yaml b/.maestro/tests/account/verifySession.yaml
index eeb0489e3e..efc45ac3ef 100644
--- a/.maestro/tests/account/verifySession.yaml
+++ b/.maestro/tests/account/verifySession.yaml
@@ -1,11 +1,13 @@
appId: ${MAESTRO_APP_ID}
---
-- tapOn: "Continue"
- takeScreenshot: build/maestro/150-Verify
- tapOn: "Enter recovery key"
- tapOn:
id: "verification-recovery_key"
- inputText: ${MAESTRO_RECOVERY_KEY}
- hideKeyboard
-- tapOn: "Confirm"
-- runFlow: ../assertions/assertHomeDisplayed.yaml
+- tapOn: "Continue"
+- extendedWaitUntil:
+ visible: "Device verified"
+ timeout: 10000
+- tapOn: "Continue"
diff --git a/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml b/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml
new file mode 100644
index 0000000000..6690dfddb4
--- /dev/null
+++ b/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml
@@ -0,0 +1,5 @@
+appId: ${MAESTRO_APP_ID}
+---
+- extendedWaitUntil:
+ visible: "Confirm that it's you"
+ timeout: 20000
diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml
index 8b41c4d259..5d5e01de84 100644
--- a/.maestro/tests/roomList/searchRoomList.yaml
+++ b/.maestro/tests/roomList/searchRoomList.yaml
@@ -7,8 +7,4 @@ appId: ${MAESTRO_APP_ID}
- tapOn: ${MAESTRO_ROOM_NAME}
# Back from timeline
- back
-- assertVisible: "MyR"
-- hideKeyboard
-# Back from search
-- back
- runFlow: ../assertions/assertHomeDisplayed.yaml
diff --git a/CHANGES.md b/CHANGES.md
index ebb1bbf863..cdb2e4006e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,30 @@
+Changes in Element X v0.4.8 (2024-04-10)
+========================================
+
+Features ✨
+----------
+ - Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579))
+ - Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580))
+ - Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601))
+ - Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650))
+
+Bugfixes 🐛
+----------
+ - Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612))
+ - Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619))
+ - Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625))
+ - Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667))
+
+Other changes
+-------------
+ - Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581))
+ - Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593))
+ - Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608))
+ - Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634))
+ - Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678))
+ - Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684))
+
+
Changes in Element X v0.4.7 (2024-03-26)
========================================
diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
index 99d940c5f5..ee277a40cf 100644
--- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt
+++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
@@ -19,6 +19,7 @@ package io.element.android.x
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -31,7 +32,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
@@ -60,7 +60,7 @@ class MainActivity : NodeActivity() {
super.onCreate(savedInstanceState)
appBindings = bindings()
appBindings.lockScreenService().handleSecureFlag(this)
- WindowCompat.setDecorFitsSystemWindows(window, false)
+ enableEdgeToEdge()
setContent {
MainContent(appBindings)
}
diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts
index 853a775b45..e59799d685 100644
--- a/appconfig/build.gradle.kts
+++ b/appconfig/build.gradle.kts
@@ -14,16 +14,13 @@
* limitations under the License.
*/
plugins {
- id("java-library")
- id("com.android.lint")
- alias(libs.plugins.kotlin.jvm)
+ id("io.element.android-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
}
-java {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
+android {
+ namespace = "io.element.android.appconfig"
}
anvil {
@@ -33,4 +30,5 @@ anvil {
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.di)
+ implementation(projects.libraries.matrix.api)
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
index c309e01277..2ad2916550 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
@@ -16,6 +16,29 @@
package io.element.android.appconfig
+import io.element.android.libraries.matrix.api.room.StateEventType
+
object TimelineConfig {
const val MAX_READ_RECEIPT_TO_DISPLAY = 3
+
+ /**
+ * Event types that will be filtered out from the timeline (i.e. not displayed).
+ */
+ val excludedEvents = listOf(
+ StateEventType.CALL_MEMBER,
+ StateEventType.ROOM_ALIASES,
+ StateEventType.ROOM_CANONICAL_ALIAS,
+ StateEventType.ROOM_GUEST_ACCESS,
+ StateEventType.ROOM_HISTORY_VISIBILITY,
+ StateEventType.ROOM_JOIN_RULES,
+ StateEventType.ROOM_PINNED_EVENTS,
+ StateEventType.ROOM_POWER_LEVELS,
+ StateEventType.ROOM_SERVER_ACL,
+ StateEventType.ROOM_TOMBSTONE,
+ StateEventType.SPACE_CHILD,
+ StateEventType.SPACE_PARENT,
+ StateEventType.POLICY_RULE_ROOM,
+ StateEventType.POLICY_RULE_SERVER,
+ StateEventType.POLICY_RULE_USER,
+ )
}
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index 4e436ec718..ab215f8394 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -65,6 +65,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.push.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.login.impl)
testImplementation(projects.tests.testutils)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
index b59c1cebdf..22300b7714 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
@@ -19,8 +19,6 @@ package io.element.android.appnav
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
-import io.element.android.libraries.matrix.api.verification.SessionVerificationService
-import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -34,16 +32,12 @@ import javax.inject.Inject
class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver,
- sessionVerificationService: SessionVerificationService,
) {
private var observingJob: Job? = null
private val displayLeftRoomMessage = roomMembershipObserver.updates
.map { !it.isUserInRoom }
- private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState
- .map { it == VerificationFlowState.Finished }
-
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch {
displayLeftRoomMessage
@@ -52,13 +46,6 @@ class LoggedInEventProcessor @Inject constructor(
displayMessage(CommonStrings.common_current_user_left_room)
}
.launchIn(this)
-
- displayVerificationSuccessfulMessage
- .filter { it }
- .onEach {
- displayMessage(CommonStrings.common_verification_complete)
- }
- .launchIn(this)
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index e4290d5bdf..2027f2868a 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -45,6 +45,7 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
+import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
@@ -53,15 +54,16 @@ import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
+import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
-import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
@@ -74,6 +76,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@@ -87,21 +91,21 @@ class LoggedInFlowNode @AssistedInject constructor(
private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
- private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
- private val ftueState: FtueState,
+ private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService,
+ private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val matrixClient: MatrixClient,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = NavTarget.RoomList,
+ initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
),
permanentNavModel = PermanentNavModel(
@@ -119,7 +123,6 @@ class LoggedInFlowNode @AssistedInject constructor(
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher,
matrixClient.roomMembershipObserver(),
- matrixClient.sessionVerificationService(),
)
override fun onBuilt() {
@@ -131,9 +134,15 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
- if (ftueState.shouldDisplayFlow.value) {
- backstack.push(NavTarget.Ftue)
- }
+ ftueService.state
+ .onEach { ftueState ->
+ when (ftueState) {
+ is FtueState.Unknown -> Unit // Nothing to do
+ is FtueState.Incomplete -> backstack.safeRoot(NavTarget.Ftue)
+ is FtueState.Complete -> backstack.safeRoot(NavTarget.RoomList)
+ }
+ }
+ .launchIn(lifecycleScope)
},
onStop = {
coroutineScope.launch {
@@ -189,6 +198,9 @@ class LoggedInFlowNode @AssistedInject constructor(
}
sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Placeholder : NavTarget
+
@Parcelize
data object LoggedInPermanent : NavTarget
@@ -212,9 +224,6 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object CreateRoom : NavTarget
- @Parcelize
- data object VerifySession : NavTarget
-
@Parcelize
data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
@@ -225,10 +234,14 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object Ftue : NavTarget
+
+ @Parcelize
+ data object RoomDirectorySearch : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
+ NavTarget.Placeholder -> createNode(buildContext)
NavTarget.LoggedInPermanent -> {
createNode(buildContext)
}
@@ -251,10 +264,6 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.CreateRoom)
}
- override fun onSessionVerificationClicked() {
- backstack.push(NavTarget.VerifySession)
- }
-
override fun onSessionConfirmRecoveryKeyClicked() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}
@@ -270,6 +279,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onReportBugClicked() {
plugins().forEach { it.onOpenBugReport() }
}
+
+ override fun onRoomDirectorySearchClicked() {
+ backstack.push(NavTarget.RoomDirectorySearch)
+ }
}
roomListEntryPoint
.nodeBuilder(this, buildContext)
@@ -299,10 +312,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins().forEach { it.onOpenBugReport() }
}
- override fun onVerifyClicked() {
- backstack.push(NavTarget.VerifySession)
- }
-
override fun onSecureBackupClicked() {
backstack.push(NavTarget.SecureBackup())
}
@@ -329,25 +338,6 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
- NavTarget.VerifySession -> {
- val callback = object : VerifySessionEntryPoint.Callback {
- override fun onEnterRecoveryKey() {
- backstack.replace(
- NavTarget.SecureBackup(
- initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey
- )
- )
- }
-
- override fun onDone() {
- backstack.pop()
- }
- }
- verifySessionEntryPoint
- .nodeBuilder(this, buildContext)
- .callback(callback)
- .build()
- }
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
@@ -372,7 +362,16 @@ class LoggedInFlowNode @AssistedInject constructor(
ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() {
- backstack.pop()
+ lifecycleScope.launch { attachRoomList() }
+ }
+ })
+ .build()
+ }
+ NavTarget.RoomDirectorySearch -> {
+ roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
+ .callback(object : RoomDirectoryEntryPoint.Callback {
+ override fun onOpenRoom(roomId: RoomId) {
+ coroutineScope.launch { attachRoom(roomId) }
}
})
.build()
@@ -380,20 +379,23 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
- suspend fun attachRoot(): Node {
- return attachChild {
+ suspend fun attachRoomList() {
+ if (!canShowRoomList()) return
+ attachChild {
backstack.singleTop(NavTarget.RoomList)
}
}
- suspend fun attachRoom(roomId: RoomId): RoomFlowNode {
- return attachChild {
+ suspend fun attachRoom(roomId: RoomId) {
+ if (!canShowRoomList()) return
+ attachChild {
backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.Room(roomId))
}
}
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
+ if (!canShowRoomList()) return@withContext
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.InviteList)
@@ -402,13 +404,17 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
+ private fun canShowRoomList(): Boolean {
+ return ftueService.state.value is FtueState.Complete
+ }
+
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState()
+ val isFtueDisplayed by ftueService.state.collectAsState()
BackstackView()
- val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
- if (!isFtueDisplayed) {
+ if (isFtueDisplayed is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
if (lockScreenState == LockScreenLockState.Locked) {
@@ -416,4 +422,10 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
}
+
+ @ContributesNode(AppScope::class)
+ class PlaceholderNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ ) : Node(buildContext, plugins = plugins)
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index e4ecdd8864..d310a02b99 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -288,7 +288,7 @@ class RootFlowNode @AssistedInject constructor(
.attachSession()
.apply {
when (deeplinkData) {
- is DeeplinkData.Root -> attachRoot()
+ is DeeplinkData.Root -> attachRoomList()
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index a9c4a6ecf8..7cb1d634b8 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -27,22 +27,32 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService
+import kotlinx.coroutines.flow.map
import javax.inject.Inject
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor,
private val pushService: PushService,
+ private val sessionVerificationService: SessionVerificationService,
) : Presenter {
@Composable
override fun present(): LoggedInState {
- LaunchedEffect(Unit) {
- // Ensure pusher is registered
- // TODO Manually select push provider for now
- val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
- val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
- pushService.registerWith(matrixClient, pushProvider, distributor)
+ val isVerified by remember {
+ sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
+ }.collectAsState(initial = false)
+
+ LaunchedEffect(isVerified) {
+ if (isVerified) {
+ // Ensure pusher is registered
+ // TODO Manually select push provider for now
+ val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
+ val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
+ pushService.registerWith(matrixClient, pushProvider, distributor)
+ }
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
index 21e412ef63..b9a537dc24 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
@@ -40,6 +40,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope
+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.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -61,6 +62,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope,
+ private val matrixClient: MatrixClient,
roomComponentFactory: RoomComponentFactory,
roomMembershipObserver: RoomMembershipObserver,
) : BaseFlowNode(
@@ -92,6 +94,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers()
+ trackVisitedRoom()
},
onResume = {
appCoroutineScope.launch {
@@ -117,6 +120,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
inputs()
}
+ private fun trackVisitedRoom() = lifecycleScope.launch {
+ matrixClient.trackRecentlyVisitedRoom(inputs.room.roomId)
+ }
+
private fun fetchRoomMembers() = lifecycleScope.launch {
inputs.room.updateMembers()
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
index ecc6a5dad0..0595ebdbea 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
@@ -33,6 +33,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.CoroutineScope
@@ -101,6 +102,7 @@ class RoomFlowNodeTest {
roomMembershipObserver = RoomMembershipObserver(),
appCoroutineScope = coroutineScope,
roomComponentFactory = FakeRoomComponentFactory(),
+ matrixClient = FakeMatrixClient(),
)
@Test
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index 079ccab17a..df17053512 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -22,13 +22,11 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
-import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
-import io.element.android.libraries.push.api.PushService
-import io.element.android.libraries.pushproviders.api.Distributor
-import io.element.android.libraries.pushproviders.api.PushProvider
+import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
+import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
@@ -73,20 +71,8 @@ class LoggedInPresenterTest {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus),
- pushService = object : PushService {
- override fun notificationStyleChanged() {
- }
-
- override fun getAvailablePushProviders(): List {
- return emptyList()
- }
-
- override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
- }
-
- override suspend fun testPush() {
- }
- }
+ pushService = FakePushService(),
+ sessionVerificationService = FakeSessionVerificationService(),
)
}
}
diff --git a/fastlane/metadata/android/en-US/changelogs/40004080.txt b/fastlane/metadata/android/en-US/changelogs/40004080.txt
new file mode 100644
index 0000000000..06f69e53ea
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40004080.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Enable room moderation feature.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
index a51ce30f9d..08095a4e33 100644
--- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
@@ -17,19 +17,14 @@
package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Poll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -45,18 +40,18 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
-import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.OnboardingBackground
+import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
-import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextButton
-import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@@ -82,6 +77,7 @@ fun AnalyticsOptInView(
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
+ background = { OnboardingBackground() },
header = { AnalyticsOptInHeader(state, onClickTerms) },
content = { AnalyticsOptInContent() },
footer = {
@@ -103,11 +99,11 @@ private fun AnalyticsOptInHeader(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
- IconTitleSubtitleMolecule(
+ PageTitle(
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
- subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
- iconImageVector = Icons.Filled.Poll
+ subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
)
val text = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_prompt_read_terms,
@@ -136,19 +132,6 @@ private fun AnalyticsOptInHeader(
}
}
-@Composable
-private fun CheckIcon() {
- Icon(
- modifier = Modifier
- .size(20.dp)
- .background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
- .padding(2.dp),
- imageVector = CompoundIcons.Check(),
- contentDescription = null,
- tint = ElementTheme.colors.textActionAccent,
- )
-}
-
@Composable
private fun AnalyticsOptInContent() {
Box(
@@ -162,20 +145,20 @@ private fun AnalyticsOptInContent() {
items = persistentListOf(
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
- iconComposable = { CheckIcon() },
+ iconVector = CompoundIcons.CheckCircle(),
),
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
- iconComposable = { CheckIcon() },
+ iconVector = CompoundIcons.CheckCircle(),
),
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_settings),
- iconComposable = { CheckIcon() },
+ iconVector = CompoundIcons.CheckCircle(),
),
),
- textStyle = ElementTheme.typography.fontBodyMdMedium,
- iconTint = ElementTheme.colors.textPrimary,
- backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
+ textStyle = ElementTheme.typography.fontBodyLgMedium,
+ iconTint = ElementTheme.colors.iconSuccessPrimary,
+ backgroundColor = ElementTheme.colors.bgActionSecondaryHovered,
)
}
}
diff --git a/features/call/src/main/res/values-hu/translations.xml b/features/call/src/main/res/values-hu/translations.xml
index c85521c392..fee5a163bb 100644
--- a/features/call/src/main/res/values-hu/translations.xml
+++ b/features/call/src/main/res/values-hu/translations.xml
@@ -1,6 +1,6 @@
"Folyamatban lévő hívás"
- "Koppints a híváshoz való visszatéréshez"
+ "Koppintson a híváshoz való visszatéréshez"
"☎️ Hívás folyamatban"
diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts
index 6cd9b30741..11f87a6d9f 100644
--- a/features/createroom/impl/build.gradle.kts
+++ b/features/createroom/impl/build.gradle.kts
@@ -69,6 +69,8 @@ dependencies {
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor)
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
index 4fb1149a01..63e2417113 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
@@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider
get() = sequenceOf(
aUserListState(),
- aUserListState().copy(
+ aUserListState(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = false,
selectionMode = SelectionMode.Multiple,
),
- aUserListState().copy(
+ aUserListState(
searchResults = SearchBarResultState.Results(
aMatrixUserList()
.mapIndexed { index, matrixUser ->
@@ -46,6 +47,9 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider
- Column(
+ UserListView(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding),
- ) {
- UserListView(
- modifier = Modifier
- .fillMaxWidth(),
- state = state,
- showBackButton = false,
- onUserSelected = { },
- onUserDeselected = {},
- )
- }
+ state = state,
+ showBackButton = false,
+ onUserSelected = {},
+ onUserDeselected = {},
+ )
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt
index 3cc009989a..0e1c448015 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt
@@ -19,17 +19,27 @@ package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.UserListStateProvider
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
+import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.components.CheckableUserRow
+import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
+import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.matrix.ui.model.getBestName
+import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UserListView(
@@ -74,6 +84,43 @@ fun UserListView(
},
)
}
+ if (!state.isSearchActive && state.recentDirectRooms.isNotEmpty()) {
+ LazyColumn {
+ item {
+ ListSectionHeader(
+ title = stringResource(id = CommonStrings.common_suggestions),
+ hasDivider = false,
+ )
+ }
+ state.recentDirectRooms.forEachIndexed { index, recentDirectRoom ->
+ item {
+ val isSelected = state.selectedUsers.any {
+ recentDirectRoom.matrixUser.userId == it.userId
+ }
+ CheckableUserRow(
+ checked = isSelected,
+ onCheckedChange = {
+ if (isSelected) {
+ state.eventSink(UserListEvents.RemoveFromSelection(recentDirectRoom.matrixUser))
+ onUserDeselected(recentDirectRoom.matrixUser)
+ } else {
+ state.eventSink(UserListEvents.AddToSelection(recentDirectRoom.matrixUser))
+ onUserSelected(recentDirectRoom.matrixUser)
+ }
+ },
+ data = CheckableUserRowData.Resolved(
+ avatarData = recentDirectRoom.matrixUser.getAvatarData(AvatarSize.UserListItem),
+ name = recentDirectRoom.matrixUser.getBestName(),
+ subtext = recentDirectRoom.matrixUser.userId.value,
+ ),
+ )
+ if (index < state.recentDirectRooms.lastIndex) {
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
index 723c650793..0638d8abbb 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
@@ -17,9 +17,12 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf
@@ -28,7 +31,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider
get() = sequenceOf(
aCreateRoomRootState(),
- aCreateRoomRootState().copy(
+ aCreateRoomRootState(
startDmAction = AsyncAction.Loading,
userListState = aMatrixUser().let {
aUserListState().copy(
@@ -39,7 +42,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized,
+ eventSink: (CreateRoomRootEvents) -> Unit = {},
+) = CreateRoomRootState(
+ applicationName = applicationName,
+ userListState = userListState,
+ startDmAction = startDmAction,
+ eventSink = eventSink,
)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
index 4f874c57dd..33707896fa 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -46,11 +47,14 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.persistentListOf
@Composable
fun CreateRoomRootView(
@@ -77,7 +81,11 @@ fun CreateRoomRootView(
) {
UserListView(
modifier = Modifier.fillMaxWidth(),
- state = state.userListState,
+ // Do not render suggestions in this case, the suggestion will be rendered
+ // by CreateRoomActionButtonsList
+ state = state.userListState.copy(
+ recentDirectRooms = persistentListOf(),
+ ),
onUserSelected = {
state.eventSink(CreateRoomRootEvents.StartDM(it))
},
@@ -89,6 +97,7 @@ fun CreateRoomRootView(
state = state,
onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = onInviteFriendsClicked,
+ onDmClicked = onOpenDM,
)
}
}
@@ -106,7 +115,7 @@ fun CreateRoomRootView(
onRetry = {
state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
- // Cancel start DM if there is no more selected user (should not happen)
+ // Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
@@ -139,18 +148,43 @@ private fun CreateRoomActionButtonsList(
state: CreateRoomRootState,
onNewRoomClicked: () -> Unit,
onInvitePeopleClicked: () -> Unit,
+ onDmClicked: (RoomId) -> Unit,
) {
- Column {
- CreateRoomActionButton(
- iconRes = CompoundDrawables.ic_compound_plus,
- text = stringResource(id = R.string.screen_create_room_action_create_room),
- onClick = onNewRoomClicked,
- )
- CreateRoomActionButton(
- iconRes = CompoundDrawables.ic_compound_share_android,
- text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
- onClick = onInvitePeopleClicked,
- )
+ LazyColumn {
+ item {
+ CreateRoomActionButton(
+ iconRes = CompoundDrawables.ic_compound_plus,
+ text = stringResource(id = R.string.screen_create_room_action_create_room),
+ onClick = onNewRoomClicked,
+ )
+ }
+ item {
+ CreateRoomActionButton(
+ iconRes = CompoundDrawables.ic_compound_share_android,
+ text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
+ onClick = onInvitePeopleClicked,
+ )
+ }
+ if (state.userListState.recentDirectRooms.isNotEmpty()) {
+ item {
+ ListSectionHeader(
+ title = stringResource(id = CommonStrings.common_suggestions),
+ hasDivider = false,
+ )
+ }
+ state.userListState.recentDirectRooms.forEach { recentDirectRoom ->
+ item {
+ MatrixUserRow(
+ modifier = Modifier.clickable(
+ onClick = {
+ onDmClicked(recentDirectRoom.roomId)
+ }
+ ),
+ matrixUser = recentDirectRoom.matrixUser,
+ )
+ }
+ }
+ }
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
index a210a6debd..31daf62513 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
@@ -30,6 +30,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
+import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
@@ -41,6 +44,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs,
@Assisted val userRepository: UserRepository,
@Assisted val userListDataStore: UserListDataStore,
+ private val matrixClient: MatrixClient,
) : UserListPresenter {
@AssistedFactory
@ContributesBinding(SessionScope::class)
@@ -54,6 +58,10 @@ class DefaultUserListPresenter @AssistedInject constructor(
@Composable
override fun present(): UserListState {
+ var recentDirectRooms by remember { mutableStateOf(emptyList()) }
+ LaunchedEffect(Unit) {
+ recentDirectRooms = matrixClient.getRecentDirectRooms()
+ }
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
@@ -82,6 +90,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
selectionMode = args.selectionMode,
+ recentDirectRooms = recentDirectRooms.toImmutableList(),
eventSink = { event ->
when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt
index a27191881e..b7b61fbe52 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt
@@ -17,6 +17,7 @@
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
@@ -28,6 +29,7 @@ data class UserListState(
val selectedUsers: ImmutableList,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
+ val recentDirectRooms: ImmutableList,
val eventSink: (UserListEvents) -> Unit,
) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt
index 193fa7e71f..fc46ae1953 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt
@@ -18,54 +18,82 @@ package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
+import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
-import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
open class UserListStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aUserListState(),
- aUserListState().copy(
+ aUserListState(
isSearchActive = false,
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
),
- aUserListState().copy(isSearchActive = true),
- aUserListState().copy(isSearchActive = true, searchQuery = "someone"),
- aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
- aUserListState().copy(
+ aUserListState(isSearchActive = true),
+ aUserListState(isSearchActive = true, searchQuery = "someone"),
+ aUserListState(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
+ aUserListState(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
),
- aUserListState().copy(
+ aUserListState(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
),
- aUserListState().copy(
+ aUserListState(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound()
),
- aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Single),
+ aUserListState(
+ isSearchActive = true,
+ searchQuery = "someone",
+ selectionMode = SelectionMode.Single,
+ ),
+ aUserListState(
+ recentDirectRooms = aRecentDirectRoomList(),
+ ),
)
}
-fun aUserListState() = UserListState(
- isSearchActive = false,
- searchQuery = "",
- searchResults = SearchBarResultState.Initial(),
- selectedUsers = persistentListOf(),
- selectionMode = SelectionMode.Single,
- showSearchLoader = false,
- eventSink = {}
+fun aUserListState(
+ searchQuery: String = "",
+ isSearchActive: Boolean = false,
+ searchResults: SearchBarResultState> = SearchBarResultState.Initial(),
+ selectedUsers: List = emptyList(),
+ showSearchLoader: Boolean = false,
+ selectionMode: SelectionMode = SelectionMode.Single,
+ recentDirectRooms: List = emptyList(),
+ eventSink: (UserListEvents) -> Unit = {},
+) = UserListState(
+ searchQuery = searchQuery,
+ isSearchActive = isSearchActive,
+ searchResults = searchResults,
+ selectedUsers = selectedUsers.toImmutableList(),
+ showSearchLoader = showSearchLoader,
+ selectionMode = selectionMode,
+ recentDirectRooms = recentDirectRooms.toImmutableList(),
+ eventSink = eventSink
)
fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()
fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList()
+
+fun aRecentDirectRoomList(
+ count: Int = 5
+): List = aMatrixUserList()
+ .take(count)
+ .map {
+ RecentDirectRoom(RoomId("!aRoom:id"), it)
+ }
diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml
index 066f3332de..3c81815d7d 100644
--- a/features/createroom/impl/src/main/res/values-be/translations.xml
+++ b/features/createroom/impl/src/main/res/values-be/translations.xml
@@ -7,7 +7,7 @@
"Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."
"Прыватны пакой (толькі па запрашэнні)"
"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."
- "Адкрыты пакой (для ўсіх)"
+ "Публічны пакой (для ўсіх)"
"Назва пакоя"
"Стварыце пакой"
"Тэма (неабавязкова)"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt
new file mode 100644
index 0000000000..36741347e5
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.createroom.impl.addpeople
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.createroom.impl.userlist.UserListEvents
+import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.features.createroom.impl.userlist.aUserListState
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class AddPeopleViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder()
+ ensureCalledOnce {
+ rule.setAddPeopleView(
+ aUserListState(
+ eventSink = eventsRecorder,
+ ),
+ onBackPressed = it
+ )
+ rule.pressBack()
+ }
+ eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
+ }
+
+ @Test
+ fun `clicking on back during search emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setAddPeopleView(
+ aUserListState(
+ isSearchActive = true,
+ eventSink = eventsRecorder,
+ ),
+ )
+ rule.pressBack()
+ eventsRecorder.assertSingle(UserListEvents.OnSearchActiveChanged(false))
+ }
+
+ @Test
+ fun `clicking on skip invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder()
+ ensureCalledOnce {
+ rule.setAddPeopleView(
+ aUserListState(
+ eventSink = eventsRecorder,
+ ),
+ onNextPressed = it
+ )
+ rule.clickOn(CommonStrings.action_skip)
+ }
+ eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
+ }
+}
+
+private fun AndroidComposeTestRule.setAddPeopleView(
+ state: UserListState,
+ onBackPressed: () -> Unit = EnsureNeverCalled(),
+ onNextPressed: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ AddPeopleView(
+ state = state,
+ onBackPressed = onBackPressed,
+ onNextPressed = onNextPressed,
+ )
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt
new file mode 100644
index 0000000000..dcb2e02347
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.createroom.impl.root
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.createroom.impl.R
+import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
+import io.element.android.features.createroom.impl.userlist.aUserListState
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.ui.model.getBestName
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class CreateRoomRootViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ eventSink = eventsRecorder,
+ ),
+ onClosePressed = it
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `clicking on New room invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ eventSink = eventsRecorder,
+ ),
+ onNewRoomClicked = it
+ )
+ rule.clickOn(R.string.screen_create_room_action_create_room)
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on Invite people invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ applicationName = "test",
+ eventSink = eventsRecorder,
+ ),
+ onInviteFriendsClicked = it
+ )
+ val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test")
+ rule.onNodeWithText(text).performClick()
+ }
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on a user suggestion invokes the expected callback`() {
+ val recentDirectRoomList = aRecentDirectRoomList()
+ val firstRoom = recentDirectRoomList[0]
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnceWithParam(firstRoom.roomId) {
+ rule.setCreateRoomRootView(
+ aCreateRoomRootState(
+ userListState = aUserListState(
+ recentDirectRooms = recentDirectRoomList
+ ),
+ eventSink = eventsRecorder,
+ ),
+ onOpenDM = it
+ )
+ rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setCreateRoomRootView(
+ state: CreateRoomRootState,
+ onClosePressed: () -> Unit = EnsureNeverCalled(),
+ onNewRoomClicked: () -> Unit = EnsureNeverCalled(),
+ onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+ onInviteFriendsClicked: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ CreateRoomRootView(
+ state = state,
+ onClosePressed = onClosePressed,
+ onNewRoomClicked = onNewRoomClicked,
+ onOpenDM = onOpenDM,
+ onInviteFriendsClicked = onInviteFriendsClicked,
+ )
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
index 579bd175f5..c4d6d9ab00 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
@@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
@@ -45,6 +46,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -66,6 +68,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userRepository,
UserListDataStore(),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -87,6 +90,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -123,6 +127,7 @@ class DefaultUserListPresenterTests {
),
userRepository,
UserListDataStore(),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -175,6 +180,7 @@ class DefaultUserListPresenterTests {
),
userRepository,
UserListDataStore(),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -200,6 +206,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
+ FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueService.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueService.kt
new file mode 100644
index 0000000000..77dd258b22
--- /dev/null
+++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueService.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.api.state
+
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * Service to manage the First Time User Experience state (aka Onboarding).
+ */
+interface FtueService {
+ /** The current state of the FTUE. */
+ val state: StateFlow
+
+ /** Reset the FTUE state. */
+ suspend fun reset()
+}
+
+/** The state of the FTUE. */
+sealed interface FtueState {
+ /** The FTUE state is unknown, nothing to do for now. */
+ data object Unknown : FtueState
+
+ /** The FTUE state is incomplete. The FTUE flow should be displayed. */
+ data object Incomplete : FtueState
+
+ /** The FTUE state is complete. The FTUE flow should not be displayed anymore. */
+ data object Complete : FtueState
+}
diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts
index 1719aecbe1..06251cfe5d 100644
--- a/features/ftue/impl/build.gradle.kts
+++ b/features/ftue/impl/build.gradle.kts
@@ -42,6 +42,8 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
+ implementation(projects.features.securebackup.api)
+ implementation(projects.features.verifysession.api)
implementation(projects.services.analytics.api)
implementation(projects.features.lockscreen.api)
implementation(projects.libraries.permissions.api)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index 652b6dddd4..cb77100d94 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -34,7 +34,8 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
-import io.element.android.features.ftue.impl.state.DefaultFtueState
+import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
+import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView
@@ -55,7 +56,7 @@ import kotlinx.parcelize.Parcelize
class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val ftueState: DefaultFtueState,
+ private val ftueState: DefaultFtueService,
private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
@@ -72,6 +73,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object Placeholder : NavTarget
+ @Parcelize
+ data object SessionVerification : NavTarget
+
@Parcelize
data object NotificationsOptIn : NavTarget
@@ -106,6 +110,14 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> {
createNode(buildContext)
}
+ NavTarget.SessionVerification -> {
+ val callback = object : FtueSessionVerificationFlowNode.Callback {
+ override fun onDone() {
+ lifecycleScope.launch { moveToNextStep() }
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
@@ -133,6 +145,9 @@ class FtueFlowNode @AssistedInject constructor(
private fun moveToNextStep() {
when (ftueState.getNextStep()) {
+ FtueStep.SessionVerification -> {
+ backstack.newRoot(NavTarget.SessionVerification)
+ }
FtueStep.NotificationsOptIn -> {
backstack.newRoot(NavTarget.NotificationsOptIn)
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
index 924824a2ec..b9f902202b 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
@@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -40,8 +40,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
-import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.OnboardingBackground
+import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -62,8 +64,9 @@ fun NotificationsOptInView(
HeaderFooterPage(
modifier = modifier
- .systemBarsPadding()
+ .statusBarsPadding()
.fillMaxSize(),
+ background = { OnboardingBackground() },
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { NotificationsOptInFooter(state) },
) {
@@ -75,11 +78,11 @@ fun NotificationsOptInView(
private fun NotificationsOptInHeader(
modifier: Modifier = Modifier,
) {
- IconTitleSubtitleMolecule(
+ PageTitle(
modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title),
- subTitle = stringResource(R.string.screen_notification_optin_subtitle),
- iconImageVector = CompoundIcons.NotificationsSolid(),
+ subtitle = stringResource(R.string.screen_notification_optin_subtitle),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.NotificationsSolid()),
)
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
new file mode 100644
index 0000000000..f59c76199a
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.sessionverification
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.securebackup.api.SecureBackupEntryPoint
+import io.element.android.features.verifysession.api.VerifySessionEntryPoint
+import io.element.android.libraries.architecture.BackstackView
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.di.SessionScope
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class FtueSessionVerificationFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val verifySessionEntryPoint: VerifySessionEntryPoint,
+ private val secureBackupEntryPoint: SecureBackupEntryPoint,
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data object EnterRecoveryKey : NavTarget
+
+ @Parcelize
+ data object CreateNewRecoveryKey : NavTarget
+ }
+
+ interface Callback : Plugin {
+ fun onDone()
+ }
+
+ private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
+ override fun onCreateNewRecoveryKey() {
+ backstack.push(NavTarget.CreateNewRecoveryKey)
+ }
+
+ override fun onDone() {
+ lifecycleScope.launch {
+ // Move to the completed state view in the verification flow
+ backstack.newRoot(NavTarget.Root)
+ }
+ }
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ is NavTarget.Root -> {
+ verifySessionEntryPoint.nodeBuilder(this, buildContext)
+ .callback(object : VerifySessionEntryPoint.Callback {
+ override fun onEnterRecoveryKey() {
+ backstack.push(NavTarget.EnterRecoveryKey)
+ }
+
+ override fun onCreateNewRecoveryKey() {
+ backstack.push(NavTarget.CreateNewRecoveryKey)
+ }
+
+ override fun onDone() {
+ plugins().forEach { it.onDone() }
+ }
+ })
+ .build()
+ }
+ is NavTarget.EnterRecoveryKey -> {
+ secureBackupEntryPoint.nodeBuilder(this, buildContext)
+ .params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
+ .callback(secureBackupEntryPointCallback)
+ .build()
+ }
+ is NavTarget.CreateNewRecoveryKey -> {
+ secureBackupEntryPoint.nodeBuilder(this, buildContext)
+ .params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
+ .callback(secureBackupEntryPointCallback)
+ .build()
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ BackstackView()
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
similarity index 79%
rename from features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
rename to features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
index f1a1c84545..87b7c3a7ee 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt
@@ -20,9 +20,11 @@ import android.Manifest
import android.os.Build
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@@ -35,14 +37,15 @@ import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
-class DefaultFtueState @Inject constructor(
+class DefaultFtueService @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
-) : FtueState {
- override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
+ private val sessionVerificationService: SessionVerificationService,
+) : FtueService {
+ override val state = MutableStateFlow(FtueState.Unknown)
override suspend fun reset() {
analyticsService.reset()
@@ -52,6 +55,10 @@ class DefaultFtueState @Inject constructor(
}
init {
+ sessionVerificationService.needsVerificationFlow
+ .onEach { updateState() }
+ .launchIn(coroutineScope)
+
analyticsService.didAskUserConsent()
.onEach { updateState() }
.launchIn(coroutineScope)
@@ -59,7 +66,12 @@ class DefaultFtueState @Inject constructor(
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
- null -> if (shouldAskNotificationPermissions()) {
+ null -> if (isSessionNotVerified()) {
+ FtueStep.SessionVerification
+ } else {
+ getNextStep(FtueStep.SessionVerification)
+ }
+ FtueStep.SessionVerification -> if (shouldAskNotificationPermissions()) {
FtueStep.NotificationsOptIn
} else {
getNextStep(FtueStep.NotificationsOptIn)
@@ -79,12 +91,17 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean {
return listOf(
+ { isSessionNotVerified() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
{ shouldDisplayLockscreenSetup() },
).any { it() }
}
+ private fun isSessionNotVerified(): Boolean {
+ return sessionVerificationService.needsVerificationFlow.value
+ }
+
private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() }
@@ -109,11 +126,15 @@ class DefaultFtueState @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() {
- shouldDisplayFlow.value = isAnyStepIncomplete()
+ state.value = when {
+ isAnyStepIncomplete() -> FtueState.Incomplete
+ else -> FtueState.Complete
+ }
}
}
sealed interface FtueStep {
+ data object SessionVerification : FtueStep
data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
data object LockscreenSetup : FtueStep
diff --git a/features/ftue/impl/src/main/res/values-be/translations.xml b/features/ftue/impl/src/main/res/values-be/translations.xml
index 413bad9132..0b46417dd3 100644
--- a/features/ftue/impl/src/main/res/values-be/translations.xml
+++ b/features/ftue/impl/src/main/res/values-be/translations.xml
@@ -2,6 +2,13 @@
"Вы можаце змяніць налады пазней."
"Дазвольце апавяшчэнні і ніколі не прапускайце іх"
+ "Адкрыйце Element на настольнай прыладзе"
+ "Націсніце на свой аватар"
+ "Выберыце %1$s"
+ "“Звязаць новую прыладу”"
+ "Выберыце %1$s"
+ "“Паказаць QR-код”"
+ "Адкрыйце Element на іншай прыладзе, каб атрымаць QR-код"
"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."
"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."
"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."
diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml
index 6ddceef57f..895c7068ff 100644
--- a/features/ftue/impl/src/main/res/values-ru/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ru/translations.xml
@@ -2,6 +2,13 @@
"Вы можете изменить настройки позже."
"Разрешите уведомления и никогда не пропустите сообщение"
+ "Откройте Element на настольном устройстве"
+ "Нажмите на свое изображение"
+ "Выбрать %1$s"
+ "\"Привязать новое устройство\""
+ "Выбрать %1$s"
+ "\"Показать QR-код\""
+ "Откройте Element на другом устройстве, чтобы получить QR-код"
"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."
"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."
"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."
diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml
index 3e8c86b761..ba227878c1 100644
--- a/features/ftue/impl/src/main/res/values/localazy.xml
+++ b/features/ftue/impl/src/main/res/values/localazy.xml
@@ -2,6 +2,13 @@
"You can change your settings later."
"Allow notifications and never miss a message"
+ "Open Element on a desktop device"
+ "Click on your avatar"
+ "Select %1$s"
+ "“Link new device”"
+ "Select %1$s"
+ "“Show QR code”"
+ "Open Element on another device to get the QR code"
"Calls, polls, search and more will be added later this year."
"Message history for encrypted rooms isn’t available yet."
"We’d love to hear from you, let us know what you think via the settings page."
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt
similarity index 70%
rename from features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
rename to features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt
index 1f0b817850..d381d945a9 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt
@@ -17,11 +17,15 @@
package io.element.android.features.ftue.impl
import android.os.Build
+import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.ftue.impl.state.DefaultFtueState
+import io.element.android.features.ftue.api.state.FtueState
+import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
+import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -32,38 +36,51 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest
import org.junit.Test
-class DefaultFtueStateTests {
+class DefaultFtueServiceTests {
@Test
- fun `given any check being false, should display flow is true`() = runTest {
+ fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
+ val sessionVerificationService = FakeSessionVerificationService().apply {
+ givenVerifiedStatus(SessionVerifiedStatus.Unknown)
+ }
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
- val state = createState(coroutineScope)
+ val state = createState(coroutineScope, sessionVerificationService)
- assertThat(state.shouldDisplayFlow.value).isTrue()
+ state.state.test {
+ // Verification state is unknown, we don't display the flow yet
+ assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
+
+ // Verification state is known, we should display the flow if any check is false
+ sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
+ }
// Cleanup
coroutineScope.cancel()
}
@Test
- fun `given all checks being true, should display flow is false`() = runTest {
+ fun `given all checks being true, FtueState is Complete`() = runTest {
val analyticsService = FakeAnalyticsService()
+ val sessionVerificationService = FakeSessionVerificationService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(
coroutineScope = coroutineScope,
+ sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
+ sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
state.updateState()
- assertThat(state.shouldDisplayFlow.value).isFalse()
+ assertThat(state.state.value).isEqualTo(FtueState.Complete)
// Cleanup
coroutineScope.cancel()
@@ -71,6 +88,10 @@ class DefaultFtueStateTests {
@Test
fun `traverse flow`() = runTest {
+ val sessionVerificationService = FakeSessionVerificationService().apply {
+ givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ givenNeedsVerification(true)
+ }
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
@@ -78,12 +99,17 @@ class DefaultFtueStateTests {
val state = createState(
coroutineScope = coroutineScope,
+ sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
val steps = mutableListOf()
+ // Session verification
+ steps.add(state.getNextStep(steps.lastOrNull()))
+ sessionVerificationService.givenNeedsVerification(false)
+
// Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted()
@@ -100,6 +126,7 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
+ FtueStep.SessionVerification,
FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn,
@@ -114,17 +141,20 @@ class DefaultFtueStateTests {
@Test
fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
+ val sessionVerificationService = FakeSessionVerificationService()
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val state = createState(
coroutineScope = coroutineScope,
+ sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
- // Skip first 2 steps
+ // Skip first 3 steps
+ sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
@@ -140,16 +170,19 @@ class DefaultFtueStateTests {
@Test
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
+ val sessionVerificationService = FakeSessionVerificationService()
val analyticsService = FakeAnalyticsService()
val lockScreenService = FakeLockScreenService()
val state = createState(
sdkIntVersion = Build.VERSION_CODES.M,
+ sessionVerificationService = sessionVerificationService,
coroutineScope = coroutineScope,
analyticsService = analyticsService,
lockScreenService = lockScreenService,
)
+ sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@@ -163,14 +196,16 @@ class DefaultFtueStateTests {
private fun createState(
coroutineScope: CoroutineScope,
+ sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
- ) = DefaultFtueState(
- sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
+ ) = DefaultFtueService(
coroutineScope = coroutineScope,
+ sessionVerificationService = sessionVerificationService,
+ sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
diff --git a/features/invitelist/impl/src/main/res/values-be/translations.xml b/features/invitelist/impl/src/main/res/values-be/translations.xml
index 36c895dcb9..9a37d82357 100644
--- a/features/invitelist/impl/src/main/res/values-be/translations.xml
+++ b/features/invitelist/impl/src/main/res/values-be/translations.xml
@@ -1,8 +1,8 @@
- "Вы ўпэўненыя, што жадаеце адхіліць запрашэнне ў %1$s?"
+ "Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?"
"Адхіліць запрашэнне"
- "Вы ўпэўненыя, што жадаеце адмовіцца ад прыватных зносін з %1$s?"
+ "Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"
"Адхіліць чат"
"Няма запрашэнняў"
"%1$s (%2$s) запрасіў вас"
diff --git a/features/leaveroom/api/src/main/res/values-be/translations.xml b/features/leaveroom/api/src/main/res/values-be/translations.xml
index 79a0789738..c8bb068fa7 100644
--- a/features/leaveroom/api/src/main/res/values-be/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-be/translations.xml
@@ -1,7 +1,7 @@
"Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння."
- "Вы ўпэўнены, што жадаеце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы."
- "Вы ўпэўнены, што жадаеце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."
- "Вы ўпэўнены, што жадаеце пакінуць пакой?"
+ "Вы ўпэўнены, што хочаце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы."
+ "Вы ўпэўнены, што жхочаце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."
+ "Вы ўпэўнены, што хочаце пакінуць пакой?"
diff --git a/features/leaveroom/api/src/main/res/values-hu/translations.xml b/features/leaveroom/api/src/main/res/values-hu/translations.xml
index c75cddfd05..8b5136f495 100644
--- a/features/leaveroom/api/src/main/res/values-hu/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-hu/translations.xml
@@ -2,6 +2,6 @@
"Biztos, hogy elhagyja ezt a beszélgetést? Ez a beszélgetés nem nyilvános, és meghívás nélkül nem fog tudni visszacsatlakozni."
"Biztos, hogy elhagyja ezt a szobát? Ön az egyedüli ember itt. Ha kilép, akkor senki sem fog tudni csatlakozni a jövőben, Önt is beleértve."
- "Biztos, hogy elhagyod ezt a szobát? Ez a szoba nem nyilvános, és meghívó nélkül nem fogsz tudni újra belépni."
- "Biztos, hogy elhagyod a szobát?"
+ "Biztos, hogy elhagyja ezt a szobát? Ez a szoba nem nyilvános, és meghívó nélkül nem fog tudni újra belépni."
+ "Biztos, hogy elhagyja a szobát?"
diff --git a/features/lockscreen/impl/src/main/res/values-be/translations.xml b/features/lockscreen/impl/src/main/res/values-be/translations.xml
index 7a7d4acf7e..2c5bd554b0 100644
--- a/features/lockscreen/impl/src/main/res/values-be/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-be/translations.xml
@@ -7,7 +7,7 @@
"Змяніць PIN-код"
"Дазволіць біяметрычную разблакіроўку"
"Выдаліць PIN-код"
- "Вы ўпэўнены, што жадаеце выдаліць PIN-код?"
+ "Вы ўпэўнены, што хочаце выдаліць PIN-код?"
"Выдаліць PIN-код?"
"Дазволіць %1$s"
"Я хацеў бы выкарыстоўваць PIN-код"
@@ -25,12 +25,12 @@
"Вы выходзіце з сістэмы"
- "У вас %1$d спроба разблакіроўкі"
- - "У вас %1$d спроб разблакіроўкі"
+ - "У вас %1$d спробы разблакіроўкі"
- "У вас %1$d спроб разблакіроўкі"
- "Няправільны PIN-код. У вас застаўся %1$d шанец"
- - "Няправільны PIN-код. У вас застаўася %1$d шанцаў"
+ - "Няправільны PIN-код. У вас застаўася %1$d шанца"
- "Няправільны PIN-код. У вас застаўася %1$d шанцаў"
"Выкарыстоўваць біяметрыю"
diff --git a/features/lockscreen/impl/src/main/res/values-sv/translations.xml b/features/lockscreen/impl/src/main/res/values-sv/translations.xml
index b89f26bae3..c0ffcd9cdf 100644
--- a/features/lockscreen/impl/src/main/res/values-sv/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-sv/translations.xml
@@ -1,4 +1,8 @@
+ "Byt PIN-kod"
+ "Tillåt biometrisk upplåsning"
+ "Ta bort PIN-kod"
+ "Ta bort PIN-koden?"
"Loggar ut …"
diff --git a/features/login/impl/src/main/res/values-hu/translations.xml b/features/login/impl/src/main/res/values-hu/translations.xml
index 86cd1e073e..d469e76630 100644
--- a/features/login/impl/src/main/res/values-hu/translations.xml
+++ b/features/login/impl/src/main/res/values-hu/translations.xml
@@ -8,7 +8,7 @@
"Itt lesznek a beszélgetései – ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez."
"Hamarosan bejelentkezik ide: %s"
"Itt lesznek a beszélgetései – ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez."
- "Hamarosan létrehozol egy fiókot itt: %s"
+ "Hamarosan létrehoz egy fiókot itt: %s"
"A Matrix.org egy nagy, ingyenes kiszolgáló a nyilvános Matrix-hálózaton, a biztonságos, decentralizált kommunikáció érdekében, amelyet a Matrix.org Alapítvány üzemeltet."
"Egyéb"
"Másik fiókszolgáltató, például a saját privát kiszolgáló vagy egy munkahelyi fiók használata."
diff --git a/features/logout/impl/src/main/res/values-be/translations.xml b/features/logout/impl/src/main/res/values-be/translations.xml
index 1398ae20d7..62425e93f6 100644
--- a/features/logout/impl/src/main/res/values-be/translations.xml
+++ b/features/logout/impl/src/main/res/values-be/translations.xml
@@ -1,6 +1,6 @@
- "Вы ўпэўнены, што жадаеце выйсці?"
+ "Вы ўпэўнены, што хочаце выйсці?"
"Выйсці"
"Выйсці"
"Выхад…"
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index c81cbebaae..aca840236e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -16,9 +16,12 @@
package io.element.android.features.messages.impl
+import android.content.Context
+import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@@ -31,11 +34,14 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.RoomScope
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.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
@@ -52,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
+ private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callback = plugins().firstOrNull()
@@ -96,6 +103,25 @@ class MessagesNode @AssistedInject constructor(
private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId)
}
+
+ private fun onLinkClicked(
+ context: Context,
+ url: String,
+ ) {
+ when (val permalink = permalinkParser.parse(Uri.parse(url))) {
+ is PermalinkData.UserLink -> {
+ callback?.onUserDataClicked(UserId(permalink.userId))
+ }
+ is PermalinkData.RoomLink -> {
+ // TODO Implement room link handling
+ }
+ is PermalinkData.FallbackLink,
+ is PermalinkData.RoomEmailInviteLink -> {
+ context.openUrlInExternalApp(url)
+ }
+ }
+ }
+
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
}
@@ -126,6 +152,7 @@ class MessagesNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
+ val context = LocalContext.current
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -137,6 +164,7 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
+ onLinkClicked = { onLinkClicked(context, it) },
onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked,
onJoinCallClicked = this::onJoinCallClicked,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 4c92585b0d..4edc943a73 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -89,6 +89,7 @@ import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -273,6 +274,7 @@ class MessagesPresenter @AssistedInject constructor(
) = launch {
when (action) {
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
+ TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.Reply,
@@ -435,6 +437,20 @@ class MessagesPresenter @AssistedInject constructor(
event.eventId?.let { timelineState.eventSink(TimelineEvents.PollEndClicked(it)) }
}
+ private suspend fun handleCopyLink(event: TimelineItem.Event) {
+ event.eventId ?: return
+ room.getPermalinkFor(event.eventId).fold(
+ onSuccess = { permalink ->
+ clipboardHelper.copyPlainText(permalink)
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
+ },
+ onFailure = {
+ Timber.e(it, "Failed to get permalink for event ${event.eventId}")
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
+ }
+ )
+ }
+
private suspend fun handleCopyContents(event: TimelineItem.Event) {
val content = when (event.content) {
is TimelineItemTextBasedContent -> event.content.body
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 91cccd88e8..9cf4375769 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -119,6 +119,7 @@ fun MessagesView(
onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Boolean,
onUserDataClicked: (UserId) -> Unit,
+ onLinkClicked: (String) -> Unit,
onPreviewAttachments: (ImmutableList) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
@@ -213,6 +214,7 @@ fun MessagesView(
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
+ onLinkClicked = onLinkClicked,
onTimestampClicked = { event ->
if (event.localSendState is LocalEventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
@@ -313,6 +315,7 @@ private fun MessagesViewContent(
state: MessagesState,
onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
+ onLinkClicked: (String) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
@@ -386,6 +389,7 @@ private fun MessagesViewContent(
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
+ onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
@@ -570,6 +574,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onEventClicked = { false },
onPreviewAttachments = {},
onUserDataClicked = {},
+ onLinkClicked = {},
onSendLocationClicked = {},
onCreatePollClicked = {},
onJoinCallClicked = {},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index f836e2f8a0..5ebedeb06f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -96,6 +96,7 @@ class ActionListPresenter @Inject constructor(
is TimelineItemStateContent -> {
buildList {
add(TimelineItemAction.Copy)
+ add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
@@ -119,6 +120,7 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
+ add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
@@ -136,6 +138,7 @@ class ActionListPresenter @Inject constructor(
add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
}
+ add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
@@ -176,6 +179,7 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
+ add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
index 10952058b5..be78037d76 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
@@ -135,6 +135,7 @@ fun aTimelineItemActionList(): ImmutableList {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
TimelineItemAction.ReportContent,
@@ -146,6 +147,7 @@ fun aTimelineItemPollActionList(): ImmutableList {
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
index f244f515f3..f61e6197c2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt
@@ -31,6 +31,7 @@ sealed class TimelineItemAction(
) {
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy)
+ data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 37e404d314..04c182a566 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -49,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@@ -96,6 +97,8 @@ class MessageComposerPresenter @Inject constructor(
private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
+ private val permalinkParser: PermalinkParser,
+ private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@@ -334,7 +337,7 @@ class MessageComposerPresenter @Inject constructor(
}
is MentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
- val link = PermalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
+ val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
}
@@ -345,6 +348,7 @@ class MessageComposerPresenter @Inject constructor(
return MessageComposerState(
richTextEditorState = richTextEditorState,
+ permalinkParser = permalinkParser,
isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index 09eb51477f..194ce1914c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
@@ -28,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList
@Stable
data class MessageComposerState(
val richTextEditorState: RichTextEditorState,
+ val permalinkParser: PermalinkParser,
val isFullScreen: Boolean,
val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index da3c0c8af7..fb7f616e12 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -16,9 +16,12 @@
package io.element.android.features.messages.impl.messagecomposer
+import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
@@ -43,6 +46,10 @@ fun aMessageComposerState(
memberSuggestions: ImmutableList = persistentListOf(),
) = MessageComposerState(
richTextEditorState = richTextEditorState,
+ permalinkParser = object : PermalinkParser {
+ override fun parse(uriString: String): PermalinkData = TODO()
+ override fun parse(uri: Uri): PermalinkData = TODO()
+ },
isFullScreen = isFullScreen,
mode = mode,
showTextFormatting = showTextFormatting,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
index 6cb2900a20..e68cf29844 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
@@ -109,6 +109,7 @@ internal fun MessageComposerView(
modifier = modifier,
state = state.richTextEditorState,
voiceMessageState = voiceMessageState.voiceMessageState,
+ permalinkParser = state.permalinkParser,
subcomposing = subcomposing,
onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
index aefadcbb25..26ad72dcf9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
@@ -39,7 +40,9 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
-class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
+class DefaultHtmlConverterProvider @Inject constructor(
+ private val permalinkParser: PermalinkParser,
+) : HtmlConverterProvider {
private val htmlConverter: MutableState = mutableStateOf(null)
@Composable
@@ -50,7 +53,10 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
- val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId)
+ val mentionSpanProvider = rememberMentionSpanProvider(
+ currentUserId = currentUserId,
+ permalinkParser = permalinkParser,
+ )
val context = LocalContext.current
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 67915bf9d4..3cd525c614 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
-import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
@@ -41,15 +40,11 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
-import io.element.android.libraries.matrix.api.encryption.BackupState
-import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
-import io.element.android.libraries.matrix.api.verification.SessionVerificationService
-import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -68,8 +63,6 @@ class TimelinePresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
@Assisted private val navigator: MessagesNavigator,
- private val verificationService: SessionVerificationService,
- private val encryptionService: EncryptionService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
@@ -101,21 +94,9 @@ class TimelinePresenter @AssistedInject constructor(
val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) }
val newItemState = remember { mutableStateOf(NewEventState.None) }
- val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
- val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
-
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
- val sessionState by remember {
- derivedStateOf {
- SessionState(
- isSessionVerified = sessionVerifiedStatus == SessionVerifiedStatus.Verified,
- isKeyBackupEnabled = keyBackupState == BackupState.ENABLED
- )
- }
- }
-
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards()
@@ -184,7 +165,6 @@ class TimelinePresenter @AssistedInject constructor(
timelineItems = timelineItems,
renderReadReceipts = renderReadReceipts,
newEventState = newItemState.value,
- sessionState = sessionState,
eventSink = { handleEvents(it) }
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
index ab9d93d678..4e2f9b8d42 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
@@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
-import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList
@@ -32,7 +31,6 @@ data class TimelineState(
val highlightedEventId: EventId?,
val paginationState: MatrixTimeline.PaginationState,
val newEventState: NewEventState,
- val sessionState: SessionState,
val eventSink: (TimelineEvents) -> Unit
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index aa35b620b9..ae7f62ebd7 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -29,7 +29,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
-import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
@@ -58,10 +57,6 @@ fun aTimelineState(
renderReadReceipts = renderReadReceipts,
highlightedEventId = null,
newEventState = NewEventState.None,
- sessionState = aSessionState(
- isSessionVerified = true,
- isKeyBackupEnabled = true,
- ),
eventSink = eventSink,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index 094ecb6c3b..56a2676f43 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -81,6 +81,7 @@ fun TimelineView(
typingNotificationState: TypingNotificationState,
roomName: String?,
onUserDataClicked: (UserId) -> Unit,
+ onLinkClicked: (String) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
@@ -140,13 +141,13 @@ fun TimelineView(
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
+ onLinkClicked = onLinkClicked,
inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
- sessionState = state.sessionState,
eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply,
)
@@ -276,6 +277,7 @@ internal fun TimelineViewPreview(
onMessageClicked = {},
onTimestampClicked = {},
onUserDataClicked = {},
+ onLinkClicked = {},
onMessageLongClicked = {},
onReactionClicked = { _, _ -> },
onReactionLongClicked = { _, _ -> },
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
index a86095b3fc..92b12b2dc8 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt
@@ -38,6 +38,7 @@ internal fun ATimelineItemEventRow(
onClick = {},
onLongClick = {},
onUserDataClick = {},
+ onLinkClicked = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index 81c10ad7d8..22bce0e873 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint
-import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -50,7 +49,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
@@ -94,7 +92,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.model.metadata
-import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -109,9 +106,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.api.permalink.PermalinkData
-import io.element.android.libraries.matrix.api.permalink.PermalinkParser
-import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
@@ -128,6 +122,7 @@ fun TimelineItemEventRow(
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
+ onLinkClicked: (String) -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
@@ -151,13 +146,6 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId)
}
- fun onMentionClicked(mention: Mention) {
- when (mention) {
- is Mention.User -> onUserDataClick(mention.userId)
- else -> Unit // TODO implement actions for other mentions being clicked
- }
- }
-
Column(modifier = modifier.fillMaxWidth()) {
if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp))
@@ -203,7 +191,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
- onMentionClicked = ::onMentionClicked,
+ onLinkClicked = onLinkClicked,
eventSink = eventSink,
)
}
@@ -222,7 +210,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
- onMentionClicked = ::onMentionClicked,
+ onLinkClicked = onLinkClicked,
eventSink = eventSink,
)
}
@@ -278,7 +266,7 @@ private fun TimelineItemEventRowContent(
onReactionClicked: (emoji: String) -> Unit,
onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
- onMentionClicked: (Mention) -> Unit,
+ onLinkClicked: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -346,7 +334,7 @@ private fun TimelineItemEventRowContent(
onTimestampClicked = {
onTimestampClicked(event)
},
- onMentionClicked = onMentionClicked,
+ onLinkClicked = onLinkClicked,
eventSink = eventSink,
)
}
@@ -429,7 +417,7 @@ private fun MessageEventBubbleContent(
@Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
- onMentionClicked: (Mention) -> Unit,
+ onLinkClicked: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@SuppressLint("ModifierParameter")
// need to rename this modifier to prevent linter false positives
@@ -530,7 +518,6 @@ private fun MessageEventBubbleContent(
modifier: Modifier = Modifier,
canShrinkContent: Boolean = false,
) {
- val context = LocalContext.current
val timestampLayoutModifier: Modifier
val contentModifier: Modifier
when {
@@ -566,20 +553,7 @@ private fun MessageEventBubbleContent(
) { onContentLayoutChanged ->
TimelineItemEventContentView(
content = event.content,
- onLinkClicked = { url ->
- when (val permalink = PermalinkParser.parse(Uri.parse(url))) {
- is PermalinkData.UserLink -> {
- onMentionClicked(Mention.User(UserId(permalink.userId)))
- }
- is PermalinkData.RoomLink -> {
- onMentionClicked(Mention.Room(permalink.getRoomId(), permalink.getRoomAlias()))
- }
- is PermalinkData.FallbackLink,
- is PermalinkData.RoomEmailInviteLink -> {
- context.openUrlInExternalApp(url)
- }
- }
- },
+ onLinkClicked = onLinkClicked,
eventSink = eventSink,
onContentLayoutChanged = onContentLayoutChanged,
modifier = contentModifier
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
index 74998afb89..a5d42d5dbf 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt
@@ -32,8 +32,6 @@ import io.element.android.features.messages.impl.timeline.components.group.Group
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
-import io.element.android.features.messages.impl.timeline.session.SessionState
-import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
@@ -46,11 +44,11 @@ fun TimelineItemGroupedEventsRow(
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
- sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
+ onLinkClicked: (String) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@@ -73,11 +71,11 @@ fun TimelineItemGroupedEventsRow(
highlightedItem = highlightedItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
- sessionState = sessionState,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
+ onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
@@ -97,11 +95,11 @@ private fun TimelineItemGroupedEventsRowContent(
highlightedItem: String?,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
- sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
+ onLinkClicked: (String) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@@ -130,11 +128,11 @@ private fun TimelineItemGroupedEventsRowContent(
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
- sessionState = sessionState,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
+ onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
@@ -170,11 +168,11 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
highlightedItem = null,
renderReadReceipts = true,
isLastOutgoingMessage = false,
- sessionState = aSessionState(),
onClick = {},
onLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
+ onLinkClicked = {},
onTimestampClicked = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
@@ -195,11 +193,11 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
highlightedItem = null,
renderReadReceipts = true,
isLastOutgoingMessage = false,
- sessionState = aSessionState(),
onClick = {},
onLongClick = {},
inReplyToClick = {},
onUserDataClick = {},
+ onLinkClicked = {},
onTimestampClicked = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt
index b91699a5ee..ba357ab573 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
+import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -166,6 +167,7 @@ internal fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
)
}
+@ExcludeFromCoverage
@Composable
private fun ContentToPreview(
reactions: ImmutableList,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
index 6acddd179d..89b223dafe 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt
@@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
-import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@@ -34,8 +33,8 @@ internal fun TimelineItemRow(
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
- sessionState: SessionState,
onUserDataClick: (UserId) -> Unit,
+ onLinkClicked: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
@@ -52,7 +51,6 @@ internal fun TimelineItemRow(
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
- sessionState = sessionState,
modifier = modifier,
)
}
@@ -79,6 +77,7 @@ internal fun TimelineItemRow(
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick,
+ onLinkClicked = onLinkClicked,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
@@ -98,11 +97,11 @@ internal fun TimelineItemRow(
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
- sessionState = sessionState,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
+ onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
index 5ef3c2ef2c..306c11aaba 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
@@ -25,17 +25,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
-import io.element.android.features.messages.impl.timeline.session.SessionState
@Composable
fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual,
- sessionState: SessionState,
modifier: Modifier = Modifier
) {
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
- is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier)
+ is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt
deleted file mode 100644
index c9706f06ac..0000000000
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.messages.impl.timeline.components.event
-
-import androidx.compose.material3.LocalTextStyle
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.TextMeasurer
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.rememberTextMeasurer
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.features.messages.impl.timeline.model.TimelineItem
-import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
-import io.element.android.libraries.core.bool.orFalse
-import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
-import io.element.android.libraries.ui.strings.CommonStrings
-import kotlin.math.ceil
-
-// Allow to not overlap the timestamp with the text, in the message bubble.
-// Compute the size of the worst case.
-data class ExtraPadding(val extraWidth: Dp)
-
-val noExtraPadding = ExtraPadding(0.dp)
-
-/**
- * See [io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView] for the related View.
- * And https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1819%253A99506 for the design.
- */
-@Composable
-fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
- val formattedTime = sentTime
- val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
- val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
-
- val textMeasurer = rememberTextMeasurer(cacheSize = 128)
- val density = LocalDensity.current
-
- var strLen = 2.dp // Extra space char
- if (isMessageEdited) {
- val editedText = stringResource(id = CommonStrings.common_edited_suffix)
- val extraLen = remember(editedText, density) { textMeasurer.getExtraPadding(editedText, density) } + 10.dp // Text + spacing
- strLen += extraLen
- }
- strLen += remember(formattedTime, density) { textMeasurer.getExtraPadding(formattedTime, density) }
- if (hasMessageSendingFailed) {
- strLen += 19.dp // Image + spacing
- // I do not know why, but adding extra widths avoid overlapping when the
- // message is edited and in error.
- if (isMessageEdited) {
- strLen += 2.dp
- }
- }
- return ExtraPadding(strLen)
-}
-
-private fun TextMeasurer.getExtraPadding(text: String, density: Density): Dp {
- val timestampTextStyle = ElementTheme.typography.fontBodyXsRegular
- val textWidth = measure(text = text, style = timestampTextStyle).size.width
- return (textWidth / density.density).dp
-}
-
-/**
- * Get a string to add to the content of the message to avoid overlapping the timestamp.
- */
-@Composable
-fun ExtraPadding.getStr(textStyle: TextStyle = LocalTextStyle.current): String {
- if (extraWidth == 0.dp) return ""
- val density = LocalDensity.current
- val textMeasurer = rememberTextMeasurer(128)
- val charWidth = remember(textStyle) { textMeasurer.measure(text = "\u00A0", style = textStyle).size.width }
- val widthPx = remember(density, extraWidth) { with(density) { extraWidth.toPx() } }
- // A space and some unbreakable spaces, always rounding the result to the next value if not a integer
- return " " + "\u00A0".repeat(ceil(widthPx / charWidth).toInt())
-}
-
-@Composable
-fun ExtraPadding.getDpSize(): Dp {
- return extraWidth
-}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt
index 77be3815e5..77854773c4 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt
@@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
- data object RetrySend : RetrySendMenuEvents
- data object RemoveFailed : RetrySendMenuEvents
+ data object Retry : RetrySendMenuEvents
+ data object Remove : RetrySendMenuEvents
data object Dismiss : RetrySendMenuEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
index bc1415829f..8745df9e53 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
@@ -41,7 +41,7 @@ class RetrySendMenuPresenter @Inject constructor(
is RetrySendMenuEvents.EventSelected -> {
selectedEvent = event.event
}
- RetrySendMenuEvents.RetrySend -> {
+ RetrySendMenuEvents.Retry -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.retrySendMessage(transactionId)
@@ -49,7 +49,7 @@ class RetrySendMenuPresenter @Inject constructor(
selectedEvent = null
}
}
- RetrySendMenuEvents.RemoveFailed -> {
+ RetrySendMenuEvents.Remove -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.cancelSend(transactionId)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt
index 1eec2547cd..ce01c18dfa 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt
@@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@@ -54,18 +53,18 @@ internal fun RetrySendMessageMenu(
}
fun onRetry() {
- state.eventSink(RetrySendMenuEvents.RetrySend)
+ state.eventSink(RetrySendMenuEvents.Retry)
}
- fun onRemoveFailed() {
- state.eventSink(RetrySendMenuEvents.RemoveFailed)
+ fun onRemove() {
+ state.eventSink(RetrySendMenuEvents.Remove)
}
RetrySendMessageMenuBottomSheet(
modifier = modifier,
isVisible = isVisible,
onRetry = ::onRetry,
- onRemoveFailed = ::onRemoveFailed,
+ onRemove = ::onRemove,
onDismiss = ::onDismiss
)
}
@@ -75,7 +74,7 @@ internal fun RetrySendMessageMenu(
private fun RetrySendMessageMenuBottomSheet(
isVisible: Boolean,
onRetry: () -> Unit,
- onRemoveFailed: () -> Unit,
+ onRemove: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -95,7 +94,10 @@ private fun RetrySendMessageMenuBottomSheet(
}
}
) {
- RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed)
+ RetrySendMenuContents(
+ onRetry = onRetry,
+ onRemove = onRemove,
+ )
// FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp))
}
@@ -106,7 +108,7 @@ private fun RetrySendMessageMenuBottomSheet(
@Composable
private fun ColumnScope.RetrySendMenuContents(
onRetry: () -> Unit,
- onRemoveFailed: () -> Unit,
+ onRemove: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
val coroutineScope = rememberCoroutineScope()
@@ -142,22 +144,16 @@ private fun ColumnScope.RetrySendMenuContents(
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
- onRemoveFailed()
+ onRemove()
}
}
)
}
-@Suppress("UNUSED_PARAMETER")
-@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
- // TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
- Column {
- RetrySendMenuContents(
- onRetry = {},
- onRemoveFailed = {},
- )
- }
+ RetrySendMessageMenu(
+ state = state,
+ )
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt
index c555352864..db0dd1ae9a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt
@@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.virtual
-import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -29,20 +28,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
-import io.element.android.features.messages.impl.timeline.session.SessionState
-import io.element.android.features.messages.impl.timeline.session.SessionStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@Composable
fun TimelineEncryptedHistoryBannerView(
- sessionState: SessionState,
modifier: Modifier = Modifier,
) {
Row(
@@ -61,26 +56,15 @@ fun TimelineEncryptedHistoryBannerView(
tint = ElementTheme.colors.iconInfoPrimary
)
Text(
- text = stringResource(sessionState.toStringResId()),
+ text = stringResource(R.string.screen_room_encrypted_history_banner),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary
)
}
}
-@StringRes
-private fun SessionState.toStringResId(): Int {
- return when {
- isSessionVerified.not() -> R.string.screen_room_encrypted_history_banner_unverified
- isKeyBackupEnabled.not() -> R.string.screen_room_encrypted_history_banner
- else -> R.string.screen_room_encrypted_history_banner // TODO strings need to be updated
- }
-}
-
@PreviewsDayNight
@Composable
-internal fun EncryptedHistoryBannerViewPreview(
- @PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
-) = ElementPreview {
- TimelineEncryptedHistoryBannerView(sessionState = sessionState)
+internal fun EncryptedHistoryBannerViewPreview() = ElementPreview {
+ TimelineEncryptedHistoryBannerView()
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index af1ef0bc3a..acd99d3704 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -41,6 +41,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@@ -67,6 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
private val fileExtensionExtractor: FileExtensionExtractor,
private val featureFlagService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider,
+ private val permalinkParser: PermalinkParser,
) {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
return when (val messageType = content.type) {
@@ -74,7 +76,10 @@ class TimelineItemContentMessageFactory @Inject constructor(
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
TimelineItemEmoteContent(
body = emoteBody,
- htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
+ htmlDocument = messageType.formatted?.toHtmlDocument(
+ permalinkParser = permalinkParser,
+ prefix = "* $senderDisplayName",
+ ),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
isEdited = content.isEdited,
)
@@ -197,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemNoticeContent(
body = body,
- htmlDocument = messageType.formatted?.toHtmlDocument(),
+ htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited,
)
@@ -206,7 +211,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd()
TimelineItemTextContent(
body = body,
- htmlDocument = messageType.formatted?.toHtmlDocument(),
+ htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
index 38125dcef7..0522379f72 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
@@ -43,6 +44,7 @@ class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
+ private val permalinkParser: PermalinkParser,
) {
suspend fun create(
currentTimelineItem: MatrixTimelineItem.Event,
@@ -80,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState,
- inReplyTo = currentTimelineItem.event.inReplyTo()?.map(),
+ inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
index 759ead5b61..3c629f23fc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetails.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@@ -34,7 +35,9 @@ data class InReplyToDetails(
val textContent: String?,
)
-fun InReplyTo.map() = when (this) {
+fun InReplyTo.map(
+ permalinkParser: PermalinkParser,
+) = when (this) {
is InReplyTo.Ready -> InReplyToDetails(
eventId = eventId,
senderId = senderId,
@@ -44,7 +47,7 @@ fun InReplyTo.map() = when (this) {
textContent = when (content) {
is MessageContent -> {
val messageContent = content as MessageContent
- (messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body
+ (messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body
}
is StickerContent -> {
val stickerContent = content as StickerContent
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionStateProvider.kt
deleted file mode 100644
index 2dd27d5456..0000000000
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionStateProvider.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.messages.impl.timeline.session
-
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-
-open class SessionStateProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(
- aSessionState(isSessionVerified = false, isKeyBackupEnabled = false),
- aSessionState(isSessionVerified = true, isKeyBackupEnabled = false),
- aSessionState(isSessionVerified = true, isKeyBackupEnabled = true),
- )
-}
-
-internal fun aSessionState(
- isSessionVerified: Boolean = false,
- isKeyBackupEnabled: Boolean = false,
-) = SessionState(
- isSessionVerified = isSessionVerified,
- isKeyBackupEnabled = isKeyBackupEnabled,
-)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
index 2fea39eeb7..3b09afb7fc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt
@@ -35,6 +35,7 @@ internal fun MessagesViewWithTypingPreview(
onEventClicked = { false },
onPreviewAttachments = {},
onUserDataClicked = {},
+ onLinkClicked = {},
onSendLocationClicked = {},
onCreatePollClicked = {},
onJoinCallClicked = {},
diff --git a/features/messages/impl/src/main/res/values-be/translations.xml b/features/messages/impl/src/main/res/values-be/translations.xml
index c91bb96d04..f2ea6cfd09 100644
--- a/features/messages/impl/src/main/res/values-be/translations.xml
+++ b/features/messages/impl/src/main/res/values-be/translations.xml
@@ -5,24 +5,24 @@
"Ежа & Напоі"
"Жывёлы & Прырода"
"Аб\'екты"
- "Усмешкі & Людзі"
+ "Усмешкі & Удзельнікі"
"Падарожжы & Месцы"
"Сімвалы"
"Заблакіраваць карыстальніка"
- "Адзначце, ці жадаеце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"
+ "Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"
"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."
"Прычына, па якой вы паскардзіліся на гэты змест"
"Камера"
"Зрабіць фота"
"Запісаць відэа"
"Далучэнне"
- "Фота & Відэа Бібліятэка"
+ "Бібліятэка фота & відэа"
"Месцазнаходжанне"
"Апытанне"
"Фармаціраванне тэксту"
"Гісторыя паведамленняў зараз недаступна."
"Гісторыя паведамленняў у гэтым пакоі недаступная. Праверце гэтую прыладу, каб убачыць гісторыю паведамленняў."
- "Вы жадаеце запрасіць іх назад?"
+ "Вы хочаце запрасіць іх назад?"
"Вы адзін у гэтым чаце"
"Апавясціць увесь пакой"
"Усе"
@@ -39,7 +39,7 @@
"Новы"
- "%1$d змена ў пакоі"
- - "%1$d змен у пакоі"
+ - "%1$d змены ў пакоі"
- "%1$d змен у пакоі"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 30413d1b9f..ab604e468e 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -76,11 +76,11 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
-import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
-import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender
@@ -231,6 +231,27 @@ class MessagesPresenterTest {
}
}
+ @Test
+ fun `present - handle action copy link`() = runTest {
+ val clipboardHelper = FakeClipboardHelper()
+ val event = aMessageEvent()
+ val matrixRoom = FakeMatrixRoom(
+ permalinkResult = { Result.success("a link") },
+ )
+ val presenter = createMessagesPresenter(
+ clipboardHelper = clipboardHelper,
+ matrixRoom = matrixRoom,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitFirstItem()
+ initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.CopyLink, event))
+ assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
+ assertThat(clipboardHelper.clipboardContents).isEqualTo("a link")
+ }
+ }
+
@Test
fun `present - handle action reply`() = runTest {
val presenter = createMessagesPresenter()
@@ -725,6 +746,8 @@ class MessagesPresenterTest {
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = permissionsPresenterFactory,
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
+ permalinkParser = FakePermalinkParser(),
+ permalinkBuilder = FakePermalinkBuilder(),
)
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
this,
@@ -741,8 +764,6 @@ class MessagesPresenterTest {
dispatchers = coroutineDispatchers,
appScope = this,
navigator = navigator,
- encryptionService = FakeEncryptionService(),
- verificationService = FakeSessionVerificationService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
endPollAction = endPollAction,
sendPollResponseAction = FakeSendPollResponseAction(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
index b931dc9860..e176e20805 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt
@@ -478,6 +478,7 @@ private fun AndroidComposeTestRule.setMessa
onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(),
onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
+ onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
onPreviewAttachments: (ImmutableList) -> Unit = EnsureNeverCalledWithParam(),
onSendLocationClicked: () -> Unit = EnsureNeverCalled(),
onCreatePollClicked: () -> Unit = EnsureNeverCalled(),
@@ -492,6 +493,7 @@ private fun AndroidComposeTestRule.setMessa
onRoomDetailsClicked = onRoomDetailsClicked,
onEventClicked = onEventClicked,
onUserDataClicked = onUserDataClicked,
+ onLinkClicked = onLinkClicked,
onPreviewAttachments = onPreviewAttachments,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
index 981e8fc8ac..c1062625f7 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
@@ -153,6 +153,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
@@ -193,6 +194,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
@@ -232,6 +234,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
@@ -272,6 +275,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
@@ -315,6 +319,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -357,6 +362,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
)
)
@@ -396,6 +402,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@@ -435,6 +442,7 @@ class ActionListPresenterTest {
displayEmojiReactions = false,
actions = persistentListOf(
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
)
)
@@ -473,6 +481,7 @@ class ActionListPresenterTest {
displayEmojiReactions = false,
actions = persistentListOf(
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
)
)
)
@@ -513,6 +522,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
)
@@ -595,6 +605,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Edit,
TimelineItemAction.Copy,
+ TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
)
@@ -632,6 +643,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply,
TimelineItemAction.Edit,
TimelineItemAction.EndPoll,
+ TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
)
@@ -668,6 +680,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.EndPoll,
+ TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
)
@@ -703,6 +716,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true,
actions = persistentListOf(
TimelineItemAction.Reply,
+ TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
)
@@ -738,6 +752,7 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
+ TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
)
)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
index 562424474c..db056dea19 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt
@@ -41,6 +41,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
@@ -57,6 +58,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(),
+ permalinkParser = FakePermalinkParser(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(
@@ -73,6 +75,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
),
matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
+ permalinkParser = FakePermalinkParser(),
),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
index 5e980514cb..290f297117 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
@@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.Mention
@@ -60,6 +61,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.api.PickerProvider
@@ -805,7 +808,14 @@ class MessageComposerPresenterTest {
@Test
fun `present - insertMention`() = runTest {
- val presenter = createPresenter(this)
+ val presenter = createPresenter(
+ coroutineScope = this,
+ permalinkBuilder = FakePermalinkBuilder(
+ result = {
+ Result.success("https://matrix.to/#/${A_USER_ID_2.value}")
+ }
+ )
+ )
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -941,6 +951,7 @@ class MessageComposerPresenterTest {
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
+ permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
) = MessageComposerPresenter(
coroutineScope,
room,
@@ -955,6 +966,8 @@ class MessageComposerPresenterTest {
TestRichTextEditorStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
+ permalinkParser = FakePermalinkParser(),
+ permalinkBuilder = permalinkBuilder,
)
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
index 276544a057..7411ceda1a 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
@@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -32,7 +33,9 @@ class DefaultHtmlConverterProviderTest {
@Test
fun `calling provide without calling Update first should throw an exception`() {
- val provider = DefaultHtmlConverterProvider()
+ val provider = DefaultHtmlConverterProvider(
+ permalinkParser = FakePermalinkParser(),
+ )
val exception = runCatching { provider.provide() }.exceptionOrNull()
@@ -41,7 +44,9 @@ class DefaultHtmlConverterProviderTest {
@Test
fun `calling provide after calling Update first should return an HtmlConverter`() {
- val provider = DefaultHtmlConverterProvider()
+ val provider = DefaultHtmlConverterProvider(
+ permalinkParser = FakePermalinkParser(),
+ )
composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index 5ee6ca8aa0..2485e57172 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -26,7 +26,6 @@ import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
-import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline
@@ -47,13 +46,11 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
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_USER_ID
-import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
-import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
@@ -89,7 +86,6 @@ class TimelinePresenterTest {
assertThat(initialState.timelineItems).isEmpty()
val loadedNoTimelineState = awaitItem()
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
- assertThat(loadedNoTimelineState.sessionState).isEqualTo(SessionState(isSessionVerified = false, isKeyBackupEnabled = false))
}
}
@@ -512,8 +508,6 @@ class TimelinePresenterTest {
dispatchers = testCoroutineDispatchers(),
appScope = this,
navigator = messagesNavigator,
- encryptionService = FakeEncryptionService(),
- verificationService = FakeSessionVerificationService(),
redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
index 78cd671e3f..44fb6270ae 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt
@@ -45,6 +45,7 @@ class TimelineViewTest {
typingNotificationState = aTypingNotificationState(),
roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(),
+ onLinkClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(),
onMessageLongClicked = EnsureNeverCalledWithParam(),
onTimestampClicked = EnsureNeverCalledWithParam(),
@@ -72,6 +73,7 @@ class TimelineViewTest {
typingNotificationState = aTypingNotificationState(),
roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(),
+ onLinkClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(),
onMessageLongClicked = EnsureNeverCalledWithParam(),
onTimestampClicked = EnsureNeverCalledWithParam(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
index afed2d8b8f..21bc5b53ac 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt
@@ -43,7 +43,6 @@ class RetrySendMenuPresenterTests {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
-
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
}
}
@@ -57,8 +56,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
-
initialState.eventSink(RetrySendMenuEvents.Dismiss)
+ assertThat(room.cancelSendCount).isEqualTo(0)
+ assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@@ -72,8 +72,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
-
- initialState.eventSink(RetrySendMenuEvents.RetrySend)
+ initialState.eventSink(RetrySendMenuEvents.Retry)
+ assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
@@ -88,8 +88,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
-
- initialState.eventSink(RetrySendMenuEvents.RetrySend)
+ initialState.eventSink(RetrySendMenuEvents.Retry)
+ assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
@@ -105,8 +105,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
-
- initialState.eventSink(RetrySendMenuEvents.RetrySend)
+ initialState.eventSink(RetrySendMenuEvents.Retry)
+ assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
@@ -121,9 +121,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
-
- initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
+ initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
+ assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@@ -137,9 +137,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
-
- initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
+ initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(0)
+ assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@@ -154,9 +154,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
-
- initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
+ initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
+ assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenuTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenuTest.kt
new file mode 100644
index 0000000000..41d6d5610f
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenuTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.timeline.components.retrysendmenu
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.messages.impl.R
+import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class RetrySendMessageMenuTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `dismiss the bottom sheet emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setRetrySendMessageMenu(
+ aRetrySendMenuState(
+ event = aTimelineItemEvent(),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ // Cannot test this for now.
+ // eventsRecorder.assertSingle(RetrySendMenuEvents.Dismiss)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `retry to send the event emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setRetrySendMessageMenu(
+ aRetrySendMenuState(
+ event = aTimelineItemEvent(),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(R.string.screen_room_retry_send_menu_send_again_action)
+ eventsRecorder.assertSingle(RetrySendMenuEvents.Retry)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `remove the event emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setRetrySendMessageMenu(
+ aRetrySendMenuState(
+ event = aTimelineItemEvent(),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_remove)
+ eventsRecorder.assertSingle(RetrySendMenuEvents.Remove)
+ }
+}
+
+private fun AndroidComposeTestRule.setRetrySendMessageMenu(
+ state: RetrySendMenuState,
+) {
+ setContent {
+ RetrySendMessageMenu(
+ state = state,
+ )
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index 98953f4827..6e475707ad 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf
@@ -664,6 +665,7 @@ class TimelineItemContentMessageFactoryTest {
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
+ permalinkParser = FakePermalinkParser(),
)
private fun createStickerContent(
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
index f07a73fd84..bf287341ad 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt
@@ -26,14 +26,27 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershi
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Test
class InReplyToDetailTest {
@Test
fun `map - with a not ready InReplyTo does not work`() {
- assertThat(InReplyTo.Pending.map()).isNull()
- assertThat(InReplyTo.NotLoaded(AN_EVENT_ID).map()).isNull()
- assertThat(InReplyTo.Error.map()).isNull()
+ assertThat(
+ InReplyTo.Pending.map(
+ permalinkParser = FakePermalinkParser()
+ )
+ ).isNull()
+ assertThat(
+ InReplyTo.NotLoaded(AN_EVENT_ID).map(
+ permalinkParser = FakePermalinkParser()
+ )
+ ).isNull()
+ assertThat(
+ InReplyTo.Error.map(
+ permalinkParser = FakePermalinkParser()
+ )
+ ).isNull()
}
@Test
@@ -48,7 +61,9 @@ class InReplyToDetailTest {
change = MembershipChange.INVITED,
)
)
- val inReplyToDetails = inReplyTo.map()
+ val inReplyToDetails = inReplyTo.map(
+ permalinkParser = FakePermalinkParser()
+ )
assertThat(inReplyToDetails).isNotNull()
assertThat(inReplyToDetails?.textContent).isNull()
}
@@ -74,7 +89,11 @@ class InReplyToDetailTest {
)
)
)
- assertThat(inReplyTo.map()?.textContent).isEqualTo("Hello!")
+ assertThat(
+ inReplyTo.map(
+ permalinkParser = FakePermalinkParser()
+ )?.textContent
+ ).isEqualTo("Hello!")
}
@Test
@@ -95,6 +114,10 @@ class InReplyToDetailTest {
)
)
)
- assertThat(inReplyTo.map()?.textContent).isEqualTo("**Hello!**")
+ assertThat(
+ inReplyTo.map(
+ permalinkParser = FakePermalinkParser()
+ )?.textContent
+ ).isEqualTo("**Hello!**")
}
}
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt
index 56dbaeb3ca..c82f1cb734 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt
@@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.poll.PollAnswer
* @property isSelected whether the user has selected this answer.
* @property isEnabled whether the answer can be voted.
* @property isWinner whether this is the winner answer in the poll.
- * @property isDisclosed whether the votes for this answer should be disclosed.
+ * @property showVotes whether the votes for this answer should be displayed.
* @property votesCount the number of votes for this answer.
* @property percentage the percentage of votes for this answer.
*/
@@ -34,7 +34,7 @@ data class PollAnswerItem(
val isSelected: Boolean,
val isEnabled: Boolean,
val isWinner: Boolean,
- val isDisclosed: Boolean,
+ val showVotes: Boolean,
val votesCount: Int,
val percentage: Float,
)
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt
index a84ef502ca..70658e65a8 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt
@@ -41,6 +41,7 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.designsystem.toEnabledColor
+import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonPlurals
@Composable
@@ -79,17 +80,36 @@ internal fun PollAnswerView(
text = answerItem.answer.text,
style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular,
)
- if (answerItem.isDisclosed) {
- Text(
- modifier = Modifier.align(Alignment.Bottom),
- text = pluralStringResource(
- id = CommonPlurals.common_poll_votes_count,
- count = answerItem.votesCount,
- answerItem.votesCount
- ),
- style = if (answerItem.isWinner) ElementTheme.typography.fontBodySmMedium else ElementTheme.typography.fontBodySmRegular,
- color = if (answerItem.isWinner) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
+ if (answerItem.showVotes) {
+ val text = pluralStringResource(
+ id = CommonPlurals.common_poll_votes_count,
+ count = answerItem.votesCount,
+ answerItem.votesCount
)
+ Row(
+ modifier = Modifier.align(Alignment.Bottom),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (answerItem.isWinner) {
+ Icon(
+ resourceId = CommonDrawables.ic_winner,
+ contentDescription = null,
+ tint = ElementTheme.colors.iconAccentTertiary,
+ )
+ Spacer(modifier = Modifier.width(2.dp))
+ Text(
+ text = text,
+ style = ElementTheme.typography.fontBodySmMedium,
+ color = ElementTheme.colors.textPrimary,
+ )
+ } else {
+ Text(
+ text = text,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
}
}
Spacer(modifier = Modifier.height(10.dp))
@@ -98,7 +118,7 @@ internal fun PollAnswerView(
color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(),
progress = {
when {
- answerItem.isDisclosed -> answerItem.percentage
+ answerItem.showVotes -> answerItem.percentage
answerItem.isSelected -> 1f
else -> 0f
}
@@ -114,7 +134,7 @@ internal fun PollAnswerView(
@Composable
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
- answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false),
+ answerItem = aPollAnswerItem(showVotes = true, isSelected = false),
)
}
@@ -122,7 +142,7 @@ internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
@Composable
internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
- answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true),
+ answerItem = aPollAnswerItem(showVotes = true, isSelected = true),
)
}
@@ -130,7 +150,7 @@ internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
@Composable
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
- answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false),
+ answerItem = aPollAnswerItem(showVotes = false, isSelected = false),
)
}
@@ -138,7 +158,7 @@ internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
@Composable
internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
- answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true),
+ answerItem = aPollAnswerItem(showVotes = false, isSelected = true),
)
}
@@ -146,7 +166,7 @@ internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
@Composable
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
PollAnswerView(
- answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false, isEnabled = false, isWinner = true),
+ answerItem = aPollAnswerItem(showVotes = true, isSelected = false, isEnabled = false, isWinner = true),
)
}
@@ -154,7 +174,7 @@ internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
@Composable
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
PollAnswerView(
- answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = true),
+ answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = true),
)
}
@@ -162,6 +182,6 @@ internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
@Composable
internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
PollAnswerView(
- answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false),
+ answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = false),
)
}
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt
index 206ee93ce0..9ec3ec3754 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt
@@ -27,11 +27,11 @@ fun aPollQuestion() = "What type of food should we have at the party?"
fun aPollAnswerItemList(
hasVotes: Boolean = true,
isEnded: Boolean = false,
- isDisclosed: Boolean = true,
+ showVotes: Boolean = true,
) = persistentListOf(
aPollAnswerItem(
answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"),
- isDisclosed = isDisclosed,
+ showVotes = showVotes,
isEnabled = !isEnded,
isWinner = isEnded,
votesCount = if (hasVotes) 5 else 0,
@@ -39,7 +39,7 @@ fun aPollAnswerItemList(
),
aPollAnswerItem(
answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"),
- isDisclosed = isDisclosed,
+ showVotes = showVotes,
isEnabled = !isEnded,
isWinner = false,
votesCount = 0,
@@ -47,7 +47,7 @@ fun aPollAnswerItemList(
),
aPollAnswerItem(
answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"),
- isDisclosed = isDisclosed,
+ showVotes = showVotes,
isEnabled = !isEnded,
isWinner = false,
isSelected = true,
@@ -55,7 +55,7 @@ fun aPollAnswerItemList(
percentage = if (hasVotes) 0.1f else 0f
),
aPollAnswerItem(
- isDisclosed = isDisclosed,
+ showVotes = showVotes,
isEnabled = !isEnded,
votesCount = if (hasVotes) 4 else 0,
percentage = if (hasVotes) 0.4f else 0f,
@@ -70,7 +70,7 @@ fun aPollAnswerItem(
isSelected: Boolean = false,
isEnabled: Boolean = true,
isWinner: Boolean = false,
- isDisclosed: Boolean = true,
+ showVotes: Boolean = true,
votesCount: Int = 4,
percentage: Float = 0.4f,
) = PollAnswerItem(
@@ -78,7 +78,7 @@ fun aPollAnswerItem(
isSelected = isSelected,
isEnabled = isEnabled,
isWinner = isWinner,
- isDisclosed = isDisclosed,
+ showVotes = showVotes,
votesCount = votesCount,
percentage = percentage
)
@@ -87,14 +87,14 @@ fun aPollContentState(
eventId: EventId? = null,
isMine: Boolean = false,
isEnded: Boolean = false,
- isDisclosed: Boolean = true,
+ showVotes: Boolean = true,
isPollEditable: Boolean = true,
hasVotes: Boolean = true,
question: String = aPollQuestion(),
pollKind: PollKind = PollKind.Disclosed,
answerItems: ImmutableList = aPollAnswerItemList(
isEnded = isEnded,
- isDisclosed = isDisclosed,
+ showVotes = showVotes,
hasVotes = hasVotes
),
) = PollContentState(
diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt
index 3862c385dd..a77753fc4c 100644
--- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt
+++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt
@@ -245,7 +245,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
- answerItems = aPollAnswerItemList(isDisclosed = false),
+ answerItems = aPollAnswerItemList(showVotes = false),
pollKind = PollKind.Undisclosed,
isPollEnded = false,
isPollEditable = false,
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt
index 3cbe132c85..a661d7a1bc 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt
@@ -60,7 +60,7 @@ class DefaultPollContentStateFactory @Inject constructor(
isSelected = isSelected,
isEnabled = !isPollEnded,
isWinner = isWinner,
- isDisclosed = content.kind.isDisclosed || isPollEnded,
+ showVotes = content.kind.isDisclosed || isPollEnded,
votesCount = answerVoteCount,
percentage = percentage,
)
diff --git a/features/poll/impl/src/main/res/values-be/translations.xml b/features/poll/impl/src/main/res/values-be/translations.xml
index 3749e1ec4c..1d0238bca3 100644
--- a/features/poll/impl/src/main/res/values-be/translations.xml
+++ b/features/poll/impl/src/main/res/values-be/translations.xml
@@ -4,11 +4,11 @@
"Паказаць вынікі толькі пасля заканчэння апытання"
"Схаваць галасы"
"Варыянт %1$d"
- "Вашы змены не былі захаваны. Вы ўпэўнены, што жадаеце вярнуцца?"
+ "Вашы змены не былі захаваны. Вы ўпэўнены, што хочаце вярнуцца?"
"Пытанне або тэма"
"Пра што апытанне?"
"Стварэнне апытання"
- "Вы ўпэўнены, што жадаеце выдаліць гэтае апытанне?"
+ "Вы ўпэўнены, што хочаце выдаліць гэтае апытанне?"
"Выдаліць апытанне"
"Рэдагаваць апытанне"
"Немагчыма знайсці бягучыя апытанні."
diff --git a/features/poll/impl/src/main/res/values-hu/translations.xml b/features/poll/impl/src/main/res/values-hu/translations.xml
index 74b83c8942..310c1a77db 100644
--- a/features/poll/impl/src/main/res/values-hu/translations.xml
+++ b/features/poll/impl/src/main/res/values-hu/translations.xml
@@ -4,7 +4,7 @@
"Eredmények megjelenítése csak a szavazás befejezése után"
"Szavazatok elrejtése"
"%1$d. lehetőség"
- "A módosítások nem lettek mentve. Biztos, hogy vissza akar lépni?"
+ "A módosítások nem lettek mentve. Biztos, hogy visszalép?"
"Kérdés vagy téma"
"Miről szól ez a szavazás?"
"Szavazás létrehozása"
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
index d39064d3a0..03058029a6 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
@@ -131,7 +131,7 @@ class PollContentStateFactoryTest {
val state = factory.create(eventTimelineItem, aPollContent(PollKind.Undisclosed))
val expectedState = aPollContentState(pollKind = PollKind.Undisclosed).let {
it.copy(
- answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) }.toImmutableList()
+ answerItems = it.answerItems.map { answerItem -> answerItem.copy(showVotes = false) }.toImmutableList()
)
}
assertThat(state).isEqualTo(expectedState)
@@ -147,10 +147,10 @@ class PollContentStateFactoryTest {
val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
- aPollAnswerItem(answer = A_POLL_ANSWER_1, isDisclosed = false, votesCount = 3, percentage = 0.3f),
- aPollAnswerItem(answer = A_POLL_ANSWER_2, isDisclosed = false, isSelected = true, votesCount = 6, percentage = 0.6f),
- aPollAnswerItem(answer = A_POLL_ANSWER_3, isDisclosed = false),
- aPollAnswerItem(answer = A_POLL_ANSWER_4, isDisclosed = false, votesCount = 1, percentage = 0.1f),
+ aPollAnswerItem(answer = A_POLL_ANSWER_1, showVotes = false, votesCount = 3, percentage = 0.3f),
+ aPollAnswerItem(answer = A_POLL_ANSWER_2, showVotes = false, isSelected = true, votesCount = 6, percentage = 0.6f),
+ aPollAnswerItem(answer = A_POLL_ANSWER_3, showVotes = false),
+ aPollAnswerItem(answer = A_POLL_ANSWER_4, showVotes = false, votesCount = 1, percentage = 0.1f),
),
)
assertThat(state).isEqualTo(expectedState)
@@ -164,7 +164,7 @@ class PollContentStateFactoryTest {
pollKind = PollKind.Undisclosed
).let {
it.copy(
- answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = true, isEnabled = false) }.toImmutableList(),
+ answerItems = it.answerItems.map { answerItem -> answerItem.copy(showVotes = true, isEnabled = false) }.toImmutableList(),
)
}
assertThat(state).isEqualTo(expectedState)
@@ -258,7 +258,7 @@ class PollContentStateFactoryTest {
isSelected: Boolean = false,
isEnabled: Boolean = true,
isWinner: Boolean = false,
- isDisclosed: Boolean = true,
+ showVotes: Boolean = true,
votesCount: Int = 0,
percentage: Float = 0f,
) = PollAnswerItem(
@@ -266,7 +266,7 @@ class PollContentStateFactoryTest {
isSelected = isSelected,
isEnabled = isEnabled,
isWinner = isWinner,
- isDisclosed = isDisclosed,
+ showVotes = showVotes,
votesCount = votesCount,
percentage = percentage,
)
diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
index 0577412604..e488d911ed 100644
--- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
+++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
@@ -45,7 +45,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onOpenBugReport()
- fun onVerifyClicked()
fun onSecureBackupClicked()
fun onOpenRoomNotificationSettings(roomId: RoomId)
}
diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts
index ee0658ddc6..edfb275f17 100644
--- a/features/preferences/impl/build.gradle.kts
+++ b/features/preferences/impl/build.gradle.kts
@@ -23,6 +23,11 @@ plugins {
android {
namespace = "io.element.android.features.preferences.impl"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
}
anvil {
@@ -44,12 +49,14 @@ dependencies {
implementation(projects.libraries.pushstore.api)
implementation(projects.libraries.indicator.api)
implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.troubleshoot.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.push.api)
implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api)
@@ -71,12 +78,14 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
+ testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
@@ -86,4 +95,6 @@ dependencies {
testImplementation(projects.services.toolbox.test)
testImplementation(projects.features.analytics.impl)
testImplementation(projects.tests.testutils)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
index c27a5ec14e..b93e02dd39 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
@@ -24,6 +24,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -47,6 +48,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -54,6 +56,7 @@ class PreferencesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val lockScreenEntryPoint: LockScreenEntryPoint,
+ private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
private val logoutEntryPoint: LogoutEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
@@ -85,6 +88,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data object NotificationSettings : NavTarget
+ @Parcelize
+ data object TroubleshootNotifications : NavTarget
+
@Parcelize
data object LockScreenSettings : NavTarget
@@ -109,10 +115,6 @@ class PreferencesFlowNode @AssistedInject constructor(
plugins().forEach { it.onOpenBugReport() }
}
- override fun onVerifyClicked() {
- plugins().forEach { it.onVerifyClicked() }
- }
-
override fun onSecureBackupClicked() {
plugins().forEach { it.onSecureBackupClicked() }
}
@@ -177,9 +179,22 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun editDefaultNotificationMode(isOneToOne: Boolean) {
backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne))
}
+
+ override fun onTroubleshootNotificationsClicked() {
+ backstack.push(NavTarget.TroubleshootNotifications)
+ }
}
createNode(buildContext, listOf(notificationSettingsCallback))
}
+ NavTarget.TroubleshootNotifications -> {
+ notificationTroubleShootEntryPoint.nodeBuilder(this, buildContext)
+ .callback(object : NotificationTroubleShootEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ })
+ .build()
+ }
is NavTarget.EditDefaultNotificationSetting -> {
val callback = object : EditDefaultNotificationSettingNode.Callback {
override fun openRoomNotificationSettings(roomId: RoomId) {
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt
index 122d13a817..621b0ed8b1 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt
@@ -35,6 +35,7 @@ class NotificationSettingsNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun editDefaultNotificationMode(isOneToOne: Boolean)
+ fun onTroubleshootNotificationsClicked()
}
private val callbacks = plugins()
@@ -43,6 +44,10 @@ class NotificationSettingsNode @AssistedInject constructor(
callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) }
}
+ private fun onTroubleshootNotificationsClicked() {
+ callbacks.forEach { it.onTroubleshootNotificationsClicked() }
+ }
+
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -50,6 +55,7 @@ class NotificationSettingsNode @AssistedInject constructor(
state = state,
onOpenEditDefault = { openEditDefault(isOneToOne = it) },
onBackPressed = ::navigateUp,
+ onTroubleshootNotificationsClicked = ::onTroubleshootNotificationsClicked,
modifier = modifier,
)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
index 7083b9f88b..9aa9cafb81 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
@@ -48,7 +48,7 @@ class NotificationSettingsPresenter @Inject constructor(
) : Presenter {
@Composable
override fun present(): NotificationSettingsState {
- val userPushStore = remember { userPushStoreFactory.create(matrixClient.sessionId) }
+ val userPushStore = remember { userPushStoreFactory.getOrCreate(matrixClient.sessionId) }
val systemNotificationsEnabled: MutableState = remember {
mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled())
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
index 2dc0c02145..dc1e972aa6 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
@@ -23,26 +23,48 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class NotificationSettingsStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
- aNotificationSettingsState(),
- aNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Loading),
- aNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Failure(Throwable("error"))),
+ aValidNotificationSettingsState(),
+ aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Loading),
+ aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Failure(Throwable("error"))),
+ aInvalidNotificationSettingsState(),
+ aInvalidNotificationSettingsState(fixFailed = true),
)
}
-fun aNotificationSettingsState(
+fun aValidNotificationSettingsState(
changeNotificationSettingAction: AsyncAction = AsyncAction.Uninitialized,
+ atRoomNotificationsEnabled: Boolean = true,
+ callNotificationsEnabled: Boolean = true,
+ inviteForMeNotificationsEnabled: Boolean = true,
+ appNotificationEnabled: Boolean = true,
+ eventSink: (NotificationSettingsEvents) -> Unit = {},
) = NotificationSettingsState(
matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
- atRoomNotificationsEnabled = true,
- callNotificationsEnabled = true,
- inviteForMeNotificationsEnabled = true,
+ atRoomNotificationsEnabled = atRoomNotificationsEnabled,
+ callNotificationsEnabled = callNotificationsEnabled,
+ inviteForMeNotificationsEnabled = inviteForMeNotificationsEnabled,
defaultGroupNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
defaultOneToOneNotificationMode = RoomNotificationMode.ALL_MESSAGES,
),
appSettings = NotificationSettingsState.AppSettings(
systemNotificationsEnabled = false,
- appNotificationsEnabled = true,
+ appNotificationsEnabled = appNotificationEnabled,
),
changeNotificationSettingAction = changeNotificationSettingAction,
- eventSink = {}
+ eventSink = eventSink,
+)
+
+fun aInvalidNotificationSettingsState(
+ fixFailed: Boolean = false,
+ eventSink: (NotificationSettingsEvents) -> Unit = {},
+) = NotificationSettingsState(
+ matrixSettings = NotificationSettingsState.MatrixSettings.Invalid(
+ fixFailed = fixFailed,
+ ),
+ appSettings = NotificationSettingsState.AppSettings(
+ systemNotificationsEnabled = false,
+ appNotificationsEnabled = true,
+ ),
+ changeNotificationSettingAction = AsyncAction.Uninitialized,
+ eventSink = eventSink,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
index e68b9fc8b5..d62f972d71 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
@@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun NotificationSettingsView(
state: NotificationSettingsState,
onOpenEditDefault: (isOneToOne: Boolean) -> Unit,
+ onTroubleshootNotificationsClicked: () -> Unit,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -77,6 +78,7 @@ fun NotificationSettingsView(
// TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
onInviteForMeNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) },
+ onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked,
)
}
AsyncActionView(
@@ -99,6 +101,7 @@ private fun NotificationSettingsContentView(
// TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged: (Boolean) -> Unit,
onInviteForMeNotificationsChanged: (Boolean) -> Unit,
+ onTroubleshootNotificationsClicked: () -> Unit,
) {
val context = LocalContext.current
if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) {
@@ -163,6 +166,13 @@ private fun NotificationSettingsContentView(
onCheckedChange = onInviteForMeNotificationsChanged
)
}
+ PreferenceCategory(title = stringResource(id = R.string.troubleshoot_notifications_entry_point_section)) {
+ PreferenceText(
+ modifier = Modifier,
+ title = stringResource(id = R.string.troubleshoot_notifications_entry_point_title),
+ onClick = onTroubleshootNotificationsClicked
+ )
+ }
}
}
@@ -204,15 +214,6 @@ internal fun NotificationSettingsViewPreview(@PreviewParameter(NotificationSetti
state = state,
onBackPressed = {},
onOpenEditDefault = {},
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun InvalidNotificationSettingsViewPreview() = ElementPreview {
- InvalidNotificationSettingsView(
- showError = false,
- onContinueClicked = {},
- onDismissError = {},
+ onTroubleshootNotificationsClicked = {},
)
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
index 3bd762794d..11f3fc3dd3 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
@@ -44,7 +44,6 @@ class PreferencesRootNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onOpenBugReport()
- fun onVerifyClicked()
fun onSecureBackupClicked()
fun onOpenAnalytics()
fun onOpenAbout()
@@ -61,10 +60,6 @@ class PreferencesRootNode @AssistedInject constructor(
plugins().forEach { it.onOpenBugReport() }
}
- private fun onVerifyClicked() {
- plugins().forEach { it.onVerifyClicked() }
- }
-
private fun onSecureBackupClicked() {
plugins().forEach { it.onSecureBackupClicked() }
}
@@ -138,7 +133,6 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenRageShake = this::onOpenBugReport,
onOpenAnalytics = this::onOpenAnalytics,
onOpenAbout = this::onOpenAbout,
- onVerifyClicked = this::onVerifyClicked,
onSecureBackupClicked = this::onSecureBackupClicked,
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
index f80ee11aba..20c28427a8 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
@@ -74,7 +74,7 @@ class PreferencesRootPresenter @Inject constructor(
}
// We should display the 'complete verification' option if the current session can be verified
- val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
+ val canVerifyUserSession by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
@@ -102,8 +102,7 @@ class PreferencesRootPresenter @Inject constructor(
myUser = matrixUser.value,
version = versionFormatter.get(),
deviceId = matrixClient.deviceId,
- showCompleteVerification = showCompleteVerification,
- showSecureBackup = !showCompleteVerification,
+ showSecureBackup = !canVerifyUserSession,
showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
index eabdc80da0..336690638d 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
@@ -24,7 +24,6 @@ data class PreferencesRootState(
val myUser: MatrixUser,
val version: String,
val deviceId: String?,
- val showCompleteVerification: Boolean,
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
index b688a493b6..3373cb6ba0 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
@@ -27,7 +27,6 @@ fun aPreferencesRootState(
myUser = myUser,
version = "Version 1.1 (1)",
deviceId = "ILAKNDNASDLK",
- showCompleteVerification = true,
showSecureBackup = true,
showSecureBackupBadge = true,
accountManagementUrl = "aUrl",
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 293889e868..21132df5c9 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -30,6 +30,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences
+import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -51,7 +52,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PreferencesRootView(
state: PreferencesRootState,
onBackPressed: () -> Unit,
- onVerifyClicked: () -> Unit,
onSecureBackupClicked: () -> Unit,
onManageAccountClicked: (url: String) -> Unit,
onOpenAnalytics: () -> Unit,
@@ -81,13 +81,6 @@ fun PreferencesRootView(
},
user = state.myUser,
)
- if (state.showCompleteVerification) {
- ListItem(
- headlineContent = { Text(text = stringResource(CommonStrings.common_verify_device)) },
- leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.CheckCircle())),
- onClick = onVerifyClicked
- )
- }
if (state.showSecureBackup) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) },
@@ -95,8 +88,6 @@ fun PreferencesRootView(
trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge },
onClick = onSecureBackupClicked,
)
- }
- if (state.showCompleteVerification || state.showSecureBackup) {
HorizontalDivider()
}
if (state.accountManagementUrl != null) {
@@ -222,6 +213,7 @@ internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvide
internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
ElementPreviewDark { ContentToPreview(matrixUser) }
+@ExcludeFromCoverage
@Composable
private fun ContentToPreview(matrixUser: MatrixUser) {
PreferencesRootView(
@@ -232,7 +224,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenDeveloperSettings = {},
onOpenAdvancedSettings = {},
onOpenAbout = {},
- onVerifyClicked = {},
onSecureBackupClicked = {},
onManageAccountClicked = {},
onOpenNotificationSettings = {},
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
index 2d590b90c1..9227eef364 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt
@@ -22,7 +22,7 @@ import android.content.Context
import coil.Coil
import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.ftue.api.state.FtueState
+import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.features.roomlist.api.migration.MigrationScreenStore
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -45,7 +45,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheIndexProvider: DefaultCacheService,
private val okHttpClient: Provider,
- private val ftueState: FtueState,
+ private val ftueService: FtueService,
private val migrationScreenStore: MigrationScreenStore,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
@@ -61,7 +61,7 @@ class DefaultClearCacheUseCase @Inject constructor(
// Clear app cache
context.cacheDir.deleteRecursively()
// Clear some settings
- ftueState.reset()
+ ftueService.reset()
// Clear migration screen store
migrationScreenStore.reset()
// Ensure the app is restarted
diff --git a/features/preferences/impl/src/main/res/values-be/translations.xml b/features/preferences/impl/src/main/res/values-be/translations.xml
index ce931f9c5f..450a47ce61 100644
--- a/features/preferences/impl/src/main/res/values-be/translations.xml
+++ b/features/preferences/impl/src/main/res/values-be/translations.xml
@@ -1,6 +1,6 @@
- "Рэжым распрацоўніка"
+ "Рэжым распрацоўшчыка"
"Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям."
"Базавы URL сервера званкоў Element"
"Задайце свой сервер Element Call."
@@ -10,7 +10,7 @@
"Калі выключыць, вашы пасведчанні аб прачытанні нікому не будуць адпраўляцца. Вы па-ранейшаму будзеце атрымліваць пасведчанні аб прачытанні ад іншых карыстальнікаў."
"Падзяліцеся прысутнасцю"
"Калі гэта выключана, вы не зможаце адпраўляць або атрымліваць апавяшчэнні аб прачытанні або апавяшчэнні аб наборы тэксту"
- "Уключыце опцыю для прагляду крыніцы паведамлення на часовай шкале."
+ "Уключыце опцыю для прагляду паведамленняў у хроніцы."
"У вас няма заблакіраваных карыстальнікаў"
"Разблакіраваць"
"Вы зноў зможаце ўбачыць усе паведамленні."
@@ -44,9 +44,11 @@
"Усе"
"Згадванні"
"Апавясціць мяне"
- "Апавясціць мяне ў @room"
+ "Апавясціць пра @room"
"Каб атрымліваць апавяшчэнні, змяніце свой %1$s."
"налады сістэмы"
"Сістэмныя апавяшчэнні выключаны"
"Апавяшчэнні"
+ "Выпраўленне непаладак"
+ "Выпраўленне непаладак з апавяшчэннямі"
diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml
index ed209124ec..60bc4b2b3e 100644
--- a/features/preferences/impl/src/main/res/values-cs/translations.xml
+++ b/features/preferences/impl/src/main/res/values-cs/translations.xml
@@ -51,4 +51,6 @@ Pokud budete pokračovat, některá nastavení se mohou změnit."
"systémová nastavení"
"Systémová oznámení byla vypnuta"
"Oznámení"
+ "Odstraňování problémů"
+ "Odstraňování problémů s upozorněními"
diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml
index 6d47e4817d..65b4923452 100644
--- a/features/preferences/impl/src/main/res/values-de/translations.xml
+++ b/features/preferences/impl/src/main/res/values-de/translations.xml
@@ -49,4 +49,6 @@ Wenn du fortfährst, können sich einige deiner Einstellungen ändern."
"Systemeinstellungen"
"Systembenachrichtigungen deaktiviert"
"Benachrichtigungen"
+ "Fehlerbehebung"
+ "Fehlerbehebung für Benachrichtigungen"
diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml
index ddfd6eec5c..9c415fea2d 100644
--- a/features/preferences/impl/src/main/res/values-fr/translations.xml
+++ b/features/preferences/impl/src/main/res/values-fr/translations.xml
@@ -49,4 +49,6 @@ Si vous continuez, il est possible que certains de vos paramètres soient modifi
"paramètres du système"
"Les notifications du système sont désactivées"
"Notifications"
+ "Dépannage"
+ "Résoudre les problèmes liés aux notifications"
diff --git a/features/preferences/impl/src/main/res/values-hu/translations.xml b/features/preferences/impl/src/main/res/values-hu/translations.xml
index f57308e21d..841695edcf 100644
--- a/features/preferences/impl/src/main/res/values-hu/translations.xml
+++ b/features/preferences/impl/src/main/res/values-hu/translations.xml
@@ -1,16 +1,16 @@
"Fejlesztői mód"
- "Engedélyezd, hogy elérd a fejlesztőknek szánt funkciókat."
+ "Engedélyezze, hogy elérje a fejlesztőknek szánt funkciókat."
"Egyéni Element Call alapwebcím"
"Egyéni alapwebcím beállítása az Element Callhoz."
- "Érvénytelen webcím, győződj meg arról, hogy szerepel-e benne a protokoll (http/https), és hogy helyes-e a cím."
- "A formázott szöveges szerkesztő letiltása, hogy kézzel írhass Markdownt."
+ "Érvénytelen webcím, győződjön meg arról, hogy szerepel-e benne a protokoll (http/https), és hogy helyes-e a cím."
+ "A formázott szöveges szerkesztő letiltása, hogy kézzel írhasson Markdownt."
"Olvasási visszaigazolások"
"Ha ki van kapcsolva, az olvasási visszaigazolások nem lesznek elküldve senkinek. A többi felhasználó olvasási visszaigazolását továbbra is meg fogja kapni."
"Jelenlét megosztása"
"Ha ki van kapcsolva, nem tud olvasási visszaigazolást vagy írási értesítést küldeni és fogadni"
- "Engedélyezd a beállítást az üzenet forrásának megjelenítéséhez az idővonalon."
+ "Engedélyezze a beállítást az üzenet forrásának megjelenítéséhez az idővonalon."
"Nincsenek letiltott felhasználók"
"Letiltás feloldása"
"Újra láthatja az összes üzenetét."
@@ -49,4 +49,6 @@ Ha folytatja, egyes beállítások megváltozhatnak."
"rendszerbeállításokat"
"A rendszerértesítések ki vannak kapcsolva"
"Értesítések"
+ "Hibaelhárítás"
+ "Értesítések hibaelhárítása"
diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml
index f9fb4c0848..8bca603eeb 100644
--- a/features/preferences/impl/src/main/res/values-in/translations.xml
+++ b/features/preferences/impl/src/main/res/values-in/translations.xml
@@ -51,4 +51,6 @@ Jika Anda melanjutkan, beberapa pengaturan Anda dapat berubah."
"pengaturan sistem"
"Pemberitahuan sistem dimatikan"
"Notifikasi"
+ "Pemecahan masalah"
+ "Pecahkan masalah notifikasi"
diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml
index 06b2cddad5..3b24cf8c80 100644
--- a/features/preferences/impl/src/main/res/values-ru/translations.xml
+++ b/features/preferences/impl/src/main/res/values-ru/translations.xml
@@ -49,4 +49,6 @@
"настройки системы"
"Системные уведомления выключены"
"Уведомления"
+ "Устранение неполадок"
+ "Уведомления об устранении неполадок"
diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml
index ddc71f8c67..f382d0ab6f 100644
--- a/features/preferences/impl/src/main/res/values-sk/translations.xml
+++ b/features/preferences/impl/src/main/res/values-sk/translations.xml
@@ -51,4 +51,6 @@ Ak budete pokračovať, niektoré z vašich nastavení sa môžu zmeniť.""nastavenia systému"
"Systémové oznámenia sú vypnuté"
"Oznámenia"
+ "Riešenie problémov"
+ "Oznámenia riešení problémov"
diff --git a/features/preferences/impl/src/main/res/values-sv/translations.xml b/features/preferences/impl/src/main/res/values-sv/translations.xml
index 7bcf1c81f0..aa4eea5a2e 100644
--- a/features/preferences/impl/src/main/res/values-sv/translations.xml
+++ b/features/preferences/impl/src/main/res/values-sv/translations.xml
@@ -2,6 +2,9 @@
"Utvecklarläge"
"Aktivera för att ha tillgång till funktionalitet för utvecklare."
+ "Anpassad bas-URL för Element Call"
+ "Ange en anpassad bas-URL för Element Call."
+ "Ogiltig URL, se till att du inkluderar protokollet (http/https) och rätt adress."
"Inaktivera rik-text-redigeraren för att skriva Markdown manuellt."
"Avblockera"
"Du kommer att kunna se alla meddelanden från dem igen."
diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml
index 492c75295a..56a5c0ba03 100644
--- a/features/preferences/impl/src/main/res/values/localazy.xml
+++ b/features/preferences/impl/src/main/res/values/localazy.xml
@@ -49,4 +49,6 @@ If you proceed, some of your settings may change."
"system settings"
"System notifications turned off"
"Notifications"
+ "Troubleshoot"
+ "Troubleshoot notifications"
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt
new file mode 100644
index 0000000000..8397d5aef6
--- /dev/null
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.preferences.impl.notifications
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.preferences.impl.R
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.pressBack
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class NotificationSettingsViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on back invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder()
+ ensureCalledOnce {
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ eventSink = eventsRecorder
+ ),
+ onBackPressed = it
+ )
+ rule.pressBack()
+ }
+ eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on troubleshoot notification invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder()
+ ensureCalledOnce {
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ eventSink = eventsRecorder
+ ),
+ onTroubleshootNotificationsClicked = it
+ )
+ rule.clickOn(R.string.troubleshoot_notifications_entry_point_title)
+ }
+ eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on group chats invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder()
+ ensureCalledOnceWithParam(false) {
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ eventSink = eventsRecorder
+ ),
+ onOpenEditDefault = it
+ )
+ rule.clickOn(R.string.screen_notification_settings_group_chats)
+ }
+ eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on direct chats invokes the expected callback`() {
+ val eventsRecorder = EventsRecorder()
+ ensureCalledOnceWithParam(true) {
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ eventSink = eventsRecorder
+ ),
+ onOpenEditDefault = it
+ )
+ rule.clickOn(R.string.screen_notification_settings_direct_chats)
+ }
+ eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on disable notifications emits the expected events`() {
+ testNotificationToggle(true)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on enable notifications emits the expected events`() {
+ testNotificationToggle(false)
+ }
+
+ private fun testNotificationToggle(initialState: Boolean) {
+ val eventsRecorder = EventsRecorder()
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ appNotificationEnabled = initialState,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(R.string.screen_notification_settings_enable_notifications)
+ eventsRecorder.assertList(
+ listOf(
+ NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
+ NotificationSettingsEvents.SetNotificationsEnabled(!initialState)
+ )
+ )
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on disable notify me on at room emits the expected events`() {
+ testAtRoomToggle(true)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on enable notify me on at room emits the expected events`() {
+ testAtRoomToggle(false)
+ }
+
+ private fun testAtRoomToggle(initialState: Boolean) {
+ val eventsRecorder = EventsRecorder()
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ atRoomNotificationsEnabled = initialState,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(R.string.screen_notification_settings_room_mention_label)
+ eventsRecorder.assertList(
+ listOf(
+ NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
+ NotificationSettingsEvents.SetAtRoomNotificationsEnabled(!initialState)
+ )
+ )
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on disable notify me on invitation emits the expected events`() {
+ testInvitationToggle(true)
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on enable notify me on invitation emits the expected events`() {
+ testInvitationToggle(false)
+ }
+
+ private fun testInvitationToggle(initialState: Boolean) {
+ val eventsRecorder = EventsRecorder()
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ inviteForMeNotificationsEnabled = initialState,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(R.string.screen_notification_settings_invite_for_me_label)
+ eventsRecorder.assertList(
+ listOf(
+ NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
+ NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(!initialState)
+ )
+ )
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `with an error configuration, clicking on continue emits the expected events`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setNotificationSettingsView(
+ state = aValidNotificationSettingsState(
+ changeNotificationSettingAction = AsyncAction.Failure(AN_EXCEPTION),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_ok)
+ eventsRecorder.assertList(
+ listOf(
+ NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
+ NotificationSettingsEvents.ClearNotificationChangeError
+ )
+ )
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `with invalid configuration, clicking on continue emits the expected events`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setNotificationSettingsView(
+ state = aInvalidNotificationSettingsState(
+ fixFailed = false,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_continue)
+ eventsRecorder.assertList(
+ listOf(
+ NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
+ NotificationSettingsEvents.FixConfigurationMismatch
+ )
+ )
+ }
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `with invalid configuration and error, clicking on OK emits the expected events`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setNotificationSettingsView(
+ state = aInvalidNotificationSettingsState(
+ fixFailed = true,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_ok)
+ eventsRecorder.assertList(
+ listOf(
+ NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
+ NotificationSettingsEvents.ClearConfigurationMismatchError
+ )
+ )
+ }
+}
+
+private fun AndroidComposeTestRule.setNotificationSettingsView(
+ state: NotificationSettingsState,
+ onOpenEditDefault: (isOneToOne: Boolean) -> Unit = EnsureNeverCalledWithParam(),
+ onTroubleshootNotificationsClicked: () -> Unit = EnsureNeverCalled(),
+ onBackPressed: () -> Unit = EnsureNeverCalled(),
+) {
+ setContent {
+ NotificationSettingsView(
+ state = state,
+ onOpenEditDefault = onOpenEditDefault,
+ onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked,
+ onBackPressed = onBackPressed,
+ )
+ }
+}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
index 3288e94eb4..8bc6e92328 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
@@ -92,7 +92,6 @@ class PreferencesRootPresenterTest {
)
)
assertThat(initialState.version).isEqualTo("A Version")
- assertThat(loadedState.showCompleteVerification).isTrue()
assertThat(loadedState.showSecureBackup).isFalse()
assertThat(loadedState.showSecureBackupBadge).isTrue()
assertThat(loadedState.accountManagementUrl).isNull()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt
index ebab2960e9..e41895440c 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt
@@ -23,25 +23,31 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
class VersionFormatterTest {
- @Test
- fun `version formatter should return simplified version for other branch`() = runTest {
- val sut = DefaultVersionFormatter(
- stringProvider = FakeStringProvider(defaultResult = VERSION),
- buildMeta = aBuildMeta(gitBranchName = "main")
- )
- assertThat(sut.get()).isEqualTo(VERSION)
- }
-
@Test
fun `version formatter should return simplified version for main branch`() = runTest {
val sut = DefaultVersionFormatter(
stringProvider = FakeStringProvider(defaultResult = VERSION),
buildMeta = aBuildMeta(
+ gitBranchName = "main",
+ versionName = "versionName",
+ versionCode = 123
+ )
+ )
+ assertThat(sut.get()).isEqualTo("${VERSION}versionName, 123")
+ }
+
+ @Test
+ fun `version formatter should return simplified version for other branch`() = runTest {
+ val sut = DefaultVersionFormatter(
+ stringProvider = FakeStringProvider(defaultResult = VERSION),
+ buildMeta = aBuildMeta(
+ versionName = "versionName",
+ versionCode = 123,
gitBranchName = "branch",
gitRevision = "1234567890",
)
)
- assertThat(sut.get()).isEqualTo("$VERSION\nbranch (1234567890)")
+ assertThat(sut.get()).isEqualTo("${VERSION}versionName, 123\nbranch (1234567890)")
}
companion object {
diff --git a/features/rageshake/api/src/main/res/values-be/translations.xml b/features/rageshake/api/src/main/res/values-be/translations.xml
index a73c7eedd5..3d9eec29d2 100644
--- a/features/rageshake/api/src/main/res/values-be/translations.xml
+++ b/features/rageshake/api/src/main/res/values-be/translations.xml
@@ -1,7 +1,7 @@
- "Пры апошнім выкарыстанні %1$s адбыўся збой. Жадаеце падзяліцца справаздачай аб збоі?"
- "Падобна, што вы трасеце тэлефон. Жадаеце адкрыць экран паведамлення пра памылку?"
+ "Пры апошнім выкарыстанні %1$s адбыўся збой. Хочаце падзяліцца справаздачай аб збоі?"
+ "Падобна, што вы трасеце тэлефон. Хочаце адкрыць экран паведамлення пра памылку?"
"Rageshake"
"Парог выяўлення"
diff --git a/features/rageshake/api/src/main/res/values-hu/translations.xml b/features/rageshake/api/src/main/res/values-hu/translations.xml
index 5b228df7a0..e2fc862b9d 100644
--- a/features/rageshake/api/src/main/res/values-hu/translations.xml
+++ b/features/rageshake/api/src/main/res/values-hu/translations.xml
@@ -1,7 +1,7 @@
- "Az %1$s összeomlott a legutóbbi használata óta. Megosztod velünk az összeomlás-jelentést?"
- "Úgy tűnik, mintha dühösen ráznád a telefont. Megnyitod a hibajelentési képernyőt?"
+ "Az %1$s összeomlott a legutóbbi használata óta. Megosztja velünk az összeomlás-jelentést?"
+ "Úgy tűnik, mintha dühösen rázná a telefont. Megnyitja a hibajelentési képernyőt?"
"Ideges rázás"
"Észlelési küszöb"
diff --git a/features/rageshake/impl/src/main/res/values-be/translations.xml b/features/rageshake/impl/src/main/res/values-be/translations.xml
index 277ba258be..f927ba3c5e 100644
--- a/features/rageshake/impl/src/main/res/values-be/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-be/translations.xml
@@ -8,10 +8,10 @@
"Апішыце праблему…"
"Калі магчыма, калі ласка, напішыце апісанне на англійскай мове."
"Апісанне занадта кароткае. Дайце больш падрабязную інфармацыю аб тым, што адбылося. Дзякуй!"
- "Адправіць часопісы збояў"
- "Дазволіць часопісы"
+ "Адправіць журналы збояў"
+ "Дазволіць журналы"
"Адправіць здымак экрана"
"Каб пераканацца, што ўсё працуе правільна, у паведамленне будуць уключаны часопісы. Каб адправіць паведамленне без часопісаў, адключыце гэтую наладу."
- "Пры апошнім выкарыстанні %1$s адбыўся збой. Жадаеце падзяліцца справаздачай аб збоі?"
- "Прагляд часопісаў"
+ "Пры апошнім выкарыстанні %1$s адбыўся збой. Хочаце падзяліцца справаздачай аб збоі?"
+ "Прагляд журналаў"
diff --git a/features/rageshake/impl/src/main/res/values-hu/translations.xml b/features/rageshake/impl/src/main/res/values-hu/translations.xml
index 616fd1d6cc..1ac8c4c285 100644
--- a/features/rageshake/impl/src/main/res/values-hu/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-hu/translations.xml
@@ -12,6 +12,6 @@
"Naplók engedélyezése"
"Képernyőkép küldése"
"A naplók szerepelni fognak az üzenetben, hogy megbizonyosodhassunk arról, hogy minden megfelelően működik-e. Ha naplók nélkül szeretné elküldeni az üzenetet, akkor kapcsolja ki ezt a beállítást."
- "Az %1$s összeomlott a legutóbbi használata óta. Megosztod velünk az összeomlás-jelentést?"
+ "Az %1$s összeomlott a legutóbbi használata óta. Megosztja velünk az összeomlás-jelentést?"
"Naplók megtekintése"
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
index 63cc745ecc..6bf8b00dd7 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
@@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
private val presenter: RoomDetailsPresenter,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
+ private val permalinkBuilder: PermalinkBuilder,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomMemberList()
@@ -84,8 +85,8 @@ class RoomDetailsNode @AssistedInject constructor(
private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
- val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
- ?: PermalinkBuilder.permalinkForRoomId(room.roomId)
+ val permalinkResult = alias?.let { permalinkBuilder.permalinkForRoomAlias(it) }
+ ?: permalinkBuilder.permalinkForRoomId(room.roomId)
permalinkResult.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
@@ -99,7 +100,7 @@ class RoomDetailsNode @AssistedInject constructor(
}
private fun onShareMember(context: Context, member: RoomMember) {
- val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId)
+ val permalinkResult = permalinkBuilder.permalinkForUser(member.userId)
permalinkResult.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index 9e4030201a..f0c96e74e2 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -78,10 +78,6 @@ class RoomDetailsPresenter @Inject constructor(
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
- val isRoomModerationEnabled by produceState(initialValue = false) {
- value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
- }
-
LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
if (canShowNotificationSettings.value) {
@@ -147,7 +143,7 @@ class RoomDetailsPresenter @Inject constructor(
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
- displayRolesAndPermissionsSettings = isRoomModerationEnabled && !room.isDm && isUserAdmin,
+ displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
eventSink = ::handleEvents,
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
index c05388bfc7..305393c822 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
@@ -34,8 +34,6 @@ import io.element.android.features.roomdetails.impl.members.moderation.RoomMembe
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@@ -50,7 +48,6 @@ class RoomMemberListPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
- private val featureFlagService: FeatureFlagService,
private val roomMembersModerationPresenter: RoomMembersModerationPresenter,
@Assisted private val navigator: RoomMemberListNavigator,
) : Presenter {
@@ -74,15 +71,7 @@ class RoomMemberListPresenter @AssistedInject constructor(
value = room.canInvite().getOrElse { false }
}
- val isRoomModerationEnabled by produceState(initialValue = false) {
- value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
- }
-
- val roomModerationState = if (isRoomModerationEnabled) {
- roomMembersModerationPresenter.present()
- } else {
- remember { roomMembersModerationPresenter.dummyState() }
- }
+ val roomModerationState = roomMembersModerationPresenter.present()
// Ensure we load the latest data when entering this screen
LaunchedEffect(Unit) {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
index bdf7385661..71cd975e18 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
@@ -46,6 +46,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val analyticsService: AnalyticsService,
+ private val permalinkBuilder: PermalinkBuilder,
presenterFactory: RoomMemberDetailsPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback : NodeInputs {
@@ -74,7 +75,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
val context = LocalContext.current
fun onShareUser() {
- val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
+ val permalinkResult = permalinkBuilder.permalinkForUser(inputs.roomMemberId)
permalinkResult.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
index 7c75a9e369..01f3ca2ea0 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
@@ -33,6 +33,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
+import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -127,6 +128,7 @@ internal fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetai
internal fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
ElementPreviewDark { ContentToPreview(state) }
+@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: RoomMemberDetailsState) {
RoomMemberDetailsView(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt
index ad18c07eb4..f35aa1f211 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/DefaultRoomMembersModerationPresenter.kt
@@ -31,8 +31,6 @@ import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.finally
import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -51,7 +49,6 @@ import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultRoomMembersModerationPresenter @Inject constructor(
private val room: MatrixRoom,
- private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) : RoomMembersModerationPresenter {
@@ -61,9 +58,8 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
private suspend fun canKick() = room.canKick().getOrDefault(false)
override suspend fun canDisplayModerationActions(): Boolean {
- val isRoomModerationEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
val isDm = room.isDm && room.isEncrypted
- return isRoomModerationEnabled && !isDm && (canBan() || canKick())
+ return !isDm && (canBan() || canKick())
}
@Composable
@@ -76,7 +72,7 @@ class DefaultRoomMembersModerationPresenter @Inject constructor(
val unbanUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction) }
val canDisplayBannedUsers by produceState(initialValue = false) {
- value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration) && !room.isDm && canBan()
+ value = !room.isDm && canBan()
}
fun handleEvent(event: RoomMembersModerationEvents) {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsPresenter.kt
index 659dc6c255..3f7224602d 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsPresenter.kt
@@ -29,9 +29,11 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
@@ -47,14 +49,22 @@ class RolesAndPermissionsPresenter @Inject constructor(
override fun present(): RolesAndPermissionsState {
val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
+ val roomMembers by room.membersStateFlow.collectAsState()
+ // Get the list of joined room members, in order to filter members present in the power
+ // level state Event, but not member of the room anymore.
+ val joinedRoomMemberIds by remember {
+ derivedStateOf {
+ roomMembers.joinedRoomMembers().map { it.userId }
+ }
+ }
val moderatorCount by remember {
derivedStateOf {
- roomInfo.userCountWithRole(RoomMember.Role.MODERATOR)
+ roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.MODERATOR)
}
}
val adminCount by remember {
derivedStateOf {
- roomInfo.userCountWithRole(RoomMember.Role.ADMIN)
+ roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.ADMIN)
}
}
val changeOwnRoleAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
@@ -108,11 +118,9 @@ class RolesAndPermissionsPresenter @Inject constructor(
}
}
- private fun MatrixRoomInfo?.userCountWithRole(role: RoomMember.Role): Int {
- return if (this != null) {
- userPowerLevels.count { (_, level) -> RoomMember.Role.forPowerLevel(level) == role }
- } else {
- 0
+ private fun MatrixRoomInfo?.userCountWithRole(joinedRoomMemberIds: List, role: RoomMember.Role): Int {
+ return this?.userPowerLevels.orEmpty().count { (userId, level) ->
+ RoomMember.Role.forPowerLevel(level) == role && userId in joinedRoomMemberIds
}
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesEvent.kt
index afb12a2e50..4176f5f9ce 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesEvent.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesEvent.kt
@@ -16,12 +16,12 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
-import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface ChangeRolesEvent {
data object ToggleSearchActive : ChangeRolesEvent
data class QueryChanged(val query: String?) : ChangeRolesEvent
- data class UserSelectionToggled(val roomMember: RoomMember) : ChangeRolesEvent
+ data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent
data object Save : ChangeRolesEvent
data object Exit : ChangeRolesEvent
data object CancelExit : ChangeRolesEvent
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesNode.kt
index 291beebe99..acd0d7c20c 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesNode.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesNode.kt
@@ -65,7 +65,7 @@ class ChangeRolesNode @AssistedInject constructor(
ChangeRolesView(
modifier = modifier,
state = state,
- onBackPressed = this::navigateUp,
+ navigateUp = this::navigateUp,
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenter.kt
index dc1c2ef70e..b4522367ec 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesPresenter.kt
@@ -42,6 +42,8 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
+import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
+import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
@@ -73,7 +75,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
var query by rememberSaveable { mutableStateOf(null) }
var searchActive by rememberSaveable { mutableStateOf(false) }
var searchResults by remember {
- mutableStateOf>>(SearchBarResultState.Initial())
+ mutableStateOf>(SearchBarResultState.Initial())
}
val selectedUsers = remember {
mutableStateOf>(persistentListOf())
@@ -89,7 +91,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
// Users who were selected but didn't have the role, so their role change was pending
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
// Users who no longer have the role
- val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }
+ val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
@@ -101,8 +103,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
LaunchedEffect(query, roomMemberState) {
val results = dataSource
.search(query.orEmpty())
- .sorted()
+ .groupedByRole()
+ println(results)
searchResults = if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} else {
@@ -129,11 +132,11 @@ class ChangeRolesPresenter @AssistedInject constructor(
}
is ChangeRolesEvent.UserSelectionToggled -> {
val newList = selectedUsers.value.toMutableList()
- val index = newList.indexOfFirst { it.userId == event.roomMember.userId }
+ val index = newList.indexOfFirst { it.userId == event.matrixUser.userId }
if (index >= 0) {
newList.removeAt(index)
} else {
- newList.add(event.roomMember.toMatrixUser())
+ newList.add(event.matrixUser)
}
selectedUsers.value = newList.toImmutableList()
}
@@ -179,16 +182,18 @@ class ChangeRolesPresenter @AssistedInject constructor(
)
}
+ private fun List.groupedByRole(): MembersByRole {
+ return MembersByRole(
+ admins = filter { it.role == RoomMember.Role.ADMIN }.sorted(),
+ moderators = filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
+ members = filter { it.role == RoomMember.Role.USER }.sorted(),
+ )
+ }
+
private fun Iterable.sorted(): ImmutableList {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
}
- private fun RoomMember.toMatrixUser() = MatrixUser(
- userId = userId,
- displayName = displayName,
- avatarUrl = avatarUrl,
- )
-
private fun CoroutineScope.save(
usersWithRole: ImmutableList,
selectedUsers: MutableState>,
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesState.kt
index 79a955df13..973363ae6a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesState.kt
@@ -16,18 +16,20 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
+import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
data class ChangeRolesState(
val role: RoomMember.Role,
val query: String?,
val isSearchActive: Boolean,
- val searchResults: SearchBarResultState>,
+ val searchResults: SearchBarResultState,
val selectedUsers: ImmutableList,
val hasPendingChanges: Boolean,
val exitState: AsyncAction,
@@ -35,3 +37,21 @@ data class ChangeRolesState(
val canChangeMemberRole: (UserId) -> Boolean,
val eventSink: (ChangeRolesEvent) -> Unit,
)
+
+data class MembersByRole(
+ val admins: ImmutableList,
+ val moderators: ImmutableList,
+ val members: ImmutableList,
+) {
+ constructor(members: List) : this(
+ admins = members.filter { it.role == RoomMember.Role.ADMIN }.sorted(),
+ moderators = members.filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
+ members = members.filter { it.role == RoomMember.Role.USER }.sorted(),
+ )
+
+ fun isEmpty() = admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
+}
+
+private fun Iterable.sorted(): ImmutableList {
+ return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesStateProvider.kt
index 69e8d72be6..c0e1a2c135 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesStateProvider.kt
@@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.ImmutableList
@@ -32,7 +33,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aChangeRolesState(),
- aChangeRolesState(role = RoomMember.Role.MODERATOR),
+ aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
aChangeRolesStateWithSelectedUsers(),
aChangeRolesStateWithSelectedUsers().copy(
@@ -41,7 +42,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider {
aChangeRolesStateWithSelectedUsers().copy(
query = "Alice",
isSearchActive = true,
- searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()),
+ searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
@@ -56,12 +57,13 @@ internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.ADMIN,
query: String? = null,
isSearchActive: Boolean = false,
- searchResults: SearchBarResultState> = SearchBarResultState.NoResultsFound(),
+ searchResults: SearchBarResultState = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList = persistentListOf(),
hasPendingChanges: Boolean = false,
exitState: AsyncAction = AsyncAction.Uninitialized,
savingState: AsyncAction = AsyncAction.Uninitialized,
canRemoveMember: (UserId) -> Boolean = { true },
+ eventSink: (ChangeRolesEvent) -> Unit = {},
) = ChangeRolesState(
role = role,
query = query,
@@ -72,12 +74,22 @@ internal fun aChangeRolesState(
exitState = exitState,
savingState = savingState,
canChangeMemberRole = canRemoveMember,
- eventSink = {},
+ eventSink = eventSink,
)
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
selectedUsers = aMatrixUserList().toImmutableList(),
- searchResults = SearchBarResultState.Results(aRoomMemberList().toImmutableList()),
+ searchResults = SearchBarResultState.Results(
+ MembersByRole(
+ members = aRoomMemberList().mapIndexed { index, roomMember ->
+ if (index % 2 == 0) {
+ roomMember.copy(membership = RoomMembershipState.INVITE)
+ } else {
+ roomMember
+ }
+ }
+ )
+ ),
hasPendingChanges = true,
canRemoveMember = { it != UserId("@alice:server.org") },
)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt
index 9510ca4272..450005ae4a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt
@@ -26,8 +26,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
@@ -36,23 +38,29 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R
-import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@@ -68,27 +76,24 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.getBestName
+import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
-import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangeRolesView(
state: ChangeRolesState,
- onBackPressed: () -> Unit,
+ navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
- val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
- BackHandler {
- if (state.isSearchActive) {
- state.eventSink(ChangeRolesEvent.ToggleSearchActive)
- } else {
- state.eventSink(ChangeRolesEvent.Exit)
- }
+ val updatedNavigateUp by rememberUpdatedState(newValue = navigateUp)
+ BackHandler(enabled = !state.isSearchActive) {
+ state.eventSink(ChangeRolesEvent.Exit)
}
Box(modifier = modifier) {
@@ -129,7 +134,9 @@ fun ChangeRolesView(
) {
val lazyListState = rememberLazyListState()
SearchBar(
- modifier = Modifier.padding(bottom = 16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
query = state.query.orEmpty(),
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
@@ -138,12 +145,12 @@ fun ChangeRolesView(
resultState = state.searchResults,
) { members ->
SearchResultsList(
- isSearchActive = true,
+ currentRole = state.role,
lazyListState = lazyListState,
searchResults = members,
selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole,
- onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
+ onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
selectedUsersList = {},
)
}
@@ -154,18 +161,18 @@ fun ChangeRolesView(
) {
Column {
SearchResultsList(
- isSearchActive = false,
+ currentRole = state.role,
lazyListState = lazyListState,
- searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(),
+ searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()),
selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole,
- onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
+ onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
selectedUsersList = { users ->
SelectedUsersRowList(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
selectedUsers = users,
onUserRemoved = {
- state.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(it.userId)))
+ state.eventSink(ChangeRolesEvent.UserSelectionToggled(it))
},
canDeselect = { state.canChangeMemberRole(it.userId) },
)
@@ -181,7 +188,7 @@ fun ChangeRolesView(
AsyncActionView(
async = state.exitState,
- onSuccess = { updatedOnBackPressed() },
+ onSuccess = { updatedNavigateUp() },
confirmationDialog = {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
@@ -229,8 +236,8 @@ fun ChangeRolesView(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultsList(
- isSearchActive: Boolean,
- searchResults: ImmutableList,
+ currentRole: RoomMember.Role,
+ searchResults: MembersByRole,
selectedUsers: ImmutableList,
canRemoveMember: (UserId) -> Boolean,
onSelectionToggled: (RoomMember) -> Unit,
@@ -243,43 +250,145 @@ private fun SearchResultsList(
item {
selectedUsersList(selectedUsers)
}
- stickyHeader {
- val textResId = if (isSearchActive) {
- CommonStrings.common_search_results
- } else {
- R.string.screen_room_member_list_room_members_header_title
- }
- Text(
- modifier = Modifier
- .background(ElementTheme.colors.bgCanvasDefault)
- .padding(horizontal = 16.dp, vertical = 8.dp)
- .fillMaxWidth(),
- text = stringResource(textResId),
- style = ElementTheme.typography.fontBodyLgMedium,
- )
- }
- items(searchResults, key = { it.userId }) { roomMember ->
- val canToggle = canRemoveMember(roomMember.userId)
- val trailingContent: @Composable (() -> Unit)? = if (canToggle) {
- {
- Checkbox(
- checked = selectedUsers.any { it.userId == roomMember.userId },
- onCheckedChange = { onSelectionToggled(roomMember) },
+ if (searchResults.admins.isNotEmpty()) {
+ stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
+ // Add a footer for the admin section in change role to moderator screen
+ if (currentRole == RoomMember.Role.MODERATOR) {
+ item {
+ Text(
+ modifier = Modifier
+ .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
+ text = stringResource(R.string.screen_room_change_role_moderators_admin_section_footer),
+ color = ElementTheme.colors.textSecondary,
+ style = ElementTheme.typography.fontBodySmRegular,
)
}
- } else {
- null
}
- MatrixUserRow(
- modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
- matrixUser = MatrixUser(
- userId = roomMember.userId,
- displayName = roomMember.displayName,
- avatarUrl = roomMember.avatarUrl,
- ),
- trailingContent = trailingContent,
- )
+ items(searchResults.admins, key = { it.userId }) { roomMember ->
+ ListMemberItem(
+ roomMember = roomMember,
+ canRemoveMember = canRemoveMember,
+ onSelectionToggled = onSelectionToggled,
+ selectedUsers = selectedUsers
+ )
+ }
}
+ if (searchResults.moderators.isNotEmpty()) {
+ stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_moderators)) }
+ items(searchResults.moderators, key = { it.userId }) { roomMember ->
+ ListMemberItem(
+ roomMember = roomMember,
+ canRemoveMember = canRemoveMember,
+ onSelectionToggled = onSelectionToggled,
+ selectedUsers = selectedUsers
+ )
+ }
+ }
+ if (searchResults.members.isNotEmpty()) {
+ stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_member_list_mode_members)) }
+ items(searchResults.members, key = { it.userId }) { roomMember ->
+ ListMemberItem(
+ roomMember = roomMember,
+ canRemoveMember = canRemoveMember,
+ onSelectionToggled = onSelectionToggled,
+ selectedUsers = selectedUsers
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ListSectionHeader(text: String) {
+ Text(
+ modifier = Modifier
+ .background(ElementTheme.colors.bgCanvasDefault)
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth(),
+ text = text,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ )
+}
+
+@Composable
+private fun ListMemberItem(
+ roomMember: RoomMember,
+ canRemoveMember: (UserId) -> Boolean,
+ onSelectionToggled: (RoomMember) -> Unit,
+ selectedUsers: ImmutableList,
+) {
+ val canToggle = canRemoveMember(roomMember.userId)
+ val trailingContent: @Composable (() -> Unit) = {
+ Checkbox(
+ checked = selectedUsers.any { it.userId == roomMember.userId },
+ onCheckedChange = { onSelectionToggled(roomMember) },
+ enabled = canToggle,
+ )
+ }
+ MemberRow(
+ modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
+ avatarData = AvatarData(roomMember.userId.value, roomMember.displayName, roomMember.avatarUrl, AvatarSize.UserListItem),
+ name = roomMember.getBestName(),
+ userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
+ isPending = roomMember.membership == RoomMembershipState.INVITE,
+ trailingContent = trailingContent,
+ )
+}
+
+@Composable
+private fun MemberRow(
+ avatarData: AvatarData,
+ name: String,
+ userId: String?,
+ isPending: Boolean,
+ modifier: Modifier = Modifier,
+ trailingContent: @Composable (() -> Unit)? = null,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = 56.dp)
+ .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Avatar(avatarData)
+ Column(
+ modifier = Modifier
+ .padding(start = 12.dp)
+ .weight(1f),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ // Name
+ Text(
+ modifier = Modifier.weight(1f, fill = false),
+ text = name,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.primary,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ )
+ // Invitation pending marker
+ if (isPending) {
+ Text(
+ modifier = Modifier.padding(start = 8.dp),
+ text = stringResource(id = R.string.screen_room_member_list_pending_header_title),
+ style = ElementTheme.typography.fontBodySmRegular.copy(fontStyle = FontStyle.Italic),
+ color = MaterialTheme.colorScheme.secondary
+ )
+ }
+ }
+ // Id
+ userId?.let {
+ Text(
+ text = userId,
+ color = MaterialTheme.colorScheme.secondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodySmRegular,
+ )
+ }
+ }
+ trailingContent?.invoke()
}
}
@@ -289,7 +398,27 @@ internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::
ElementPreview {
ChangeRolesView(
state = state,
- onBackPressed = {},
+ navigateUp = {},
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun PendingMemberRowWithLongNamePreview() {
+ ElementPreview {
+ MemberRow(
+ avatarData = AvatarData("userId", "A very long name that should be truncated", "https://example.com/avatar.png", AvatarSize.UserListItem),
+ name = "A very long name that should be truncated",
+ userId = "@alice:matrix.org",
+ isPending = true,
+ trailingContent = {
+ Checkbox(
+ checked = true,
+ onCheckedChange = {},
+ enabled = true,
+ )
+ }
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt
index 1d57545a0b..c9e09c7c68 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt
@@ -17,8 +17,9 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.permissions
import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -80,29 +81,35 @@ fun ChangeRoomPermissionsView(
)
}
) { padding ->
- Column(modifier = Modifier.padding(padding)) {
+ LazyColumn(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize()
+ ) {
for ((index, permissionItem) in state.items.withIndex()) {
- ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
- SelectRoleItem(
- permissionsItem = permissionItem,
- role = RoomMember.Role.ADMIN,
- currentPermissions = state.currentPermissions
- ) { item, role ->
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
- }
- SelectRoleItem(
- permissionsItem = permissionItem,
- role = RoomMember.Role.MODERATOR,
- currentPermissions = state.currentPermissions
- ) { item, role ->
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
- }
- SelectRoleItem(
- permissionsItem = permissionItem,
- role = RoomMember.Role.USER,
- currentPermissions = state.currentPermissions
- ) { item, role ->
- state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
+ item {
+ ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
+ SelectRoleItem(
+ permissionsItem = permissionItem,
+ role = RoomMember.Role.ADMIN,
+ currentPermissions = state.currentPermissions
+ ) { item, role ->
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
+ }
+ SelectRoleItem(
+ permissionsItem = permissionItem,
+ role = RoomMember.Role.MODERATOR,
+ currentPermissions = state.currentPermissions
+ ) { item, role ->
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
+ }
+ SelectRoleItem(
+ permissionsItem = permissionItem,
+ role = RoomMember.Role.USER,
+ currentPermissions = state.currentPermissions
+ ) { item, role ->
+ state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
+ }
}
}
}
diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml
index 9419d246cc..be2ac558fd 100644
--- a/features/roomdetails/impl/src/main/res/values-be/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml
@@ -29,7 +29,12 @@
"Паніжэнне ўзроўню"
"Вы не зможаце адмяніць гэтае змяненне, бо паніжаеце сябе. Калі вы апошні адміністратар у пакоі, вярнуць права будзе немагчыма."
"Панізіць сябе?"
+ "%1$s (У чаканні)"
+ "(У чаканні)"
"Рэдагаваць мадэратараў"
+ "Адміністратары"
+ "Мадэратары"
+ "Удзельнікі"
"У вас ёсць незахаваныя змены."
"Захаваць змены?"
"Дадаць тэму"
@@ -46,8 +51,8 @@
"Запрасіць карыстальникаў"
"Пакінуць размову"
"Пакінуць пакой"
- "Карыстальніцкі"
- "Па змаўчанні"
+ "Уласныя"
+ "Стандартныя"
"Апавяшчэнні"
"Ролі і дазволы"
"Назва пакоя"
@@ -62,7 +67,7 @@
"Блакіроўка %1$s"
- "%1$d карыстальнік"
- - "%1$d карыстальнікаў"
+ - "%1$d карыстальніка"
- "%1$d карыстальнікаў"
"Выдаліць і заблакіраваць удзельніка"
@@ -83,14 +88,14 @@
"Удзельнікі пакоя"
"Разблакіроўка %1$s"
"Дазволіць карыстальніцкую наладу"
- "Калі гэта ўключыць, ваша налада па змаўчанні будзе адменена"
+ "Калі гэта ўключыць, ваша налада прадвызначана будзе адменена"
"Апавяшчаць мяне ў гэтым чаце для"
- "Вы можаце змяніць яго ў сваім %1$s."
- "глабальныя налады"
- "Налада па змаўчанні"
+ "Вы можаце змяніць гэта ў %1$s."
+ "асноўных наладах"
+ "Стандартная налада"
"Выдаліць карыстальніцкую наладу"
"Падчас загрузкі налад апавяшчэнняў адбылася памылка."
- "Не атрымалася аднавіць рэжым па змаўчанні, паспрабуйце яшчэ раз."
+ "Не атрымалася аднавіць прадвызначаны рэжым, паспрабуйце яшчэ раз."
"Не ўдалося наладзіць рэжым, паспрабуйце яшчэ раз."
"Ваш хатні сервер не падтрымлівае гэту опцыю ў зашыфраваных пакоях, вы не атрымаеце апавяшчэнне ў гэтым пакоі."
"Усе паведамленні"
diff --git a/features/roomdetails/impl/src/main/res/values-bg/translations.xml b/features/roomdetails/impl/src/main/res/values-bg/translations.xml
index 80223c8cfa..5cc105f657 100644
--- a/features/roomdetails/impl/src/main/res/values-bg/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-bg/translations.xml
@@ -5,6 +5,7 @@
"Отблокиране"
"Отблокиране на потребителя"
"Анкети"
+ "Членове"
"Добавяне на тема"
"Вече е член"
"Вече е бил поканен"
diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
index a45c7e25a1..93e7fcf389 100644
--- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
@@ -29,7 +29,11 @@
"Degradovat"
"Tuto změnu nebudete moci vrátit zpět, protože sami degradujete, pokud jste posledním privilegovaným uživatelem v místnosti, nebude možné znovu získat oprávnění."
"Degradovat se?"
+ "%1$s (čekající)"
"Upravit moderátory"
+ "Správci"
+ "Moderátoři"
+ "Členové"
"Máte neuložené změny."
"Uložit změny?"
"Přidat téma"
diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml
index fe16998a72..980f149bad 100644
--- a/features/roomdetails/impl/src/main/res/values-de/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml
@@ -29,7 +29,11 @@
"Zurückstufen"
"Du stufst dich selbst herab. Diese Änderung kann nicht rückgängig gemacht werden. Wenn du der letzte Benutzer mit dieser Rolle bist, ist es nicht möglich, diese Rolle wiederzuerlangen."
"Möchtest Du Dich selbst herabstufen?"
+ "%1$s (Ausstehend)"
"Moderatoren bearbeiten"
+ "Administratoren"
+ "Moderatoren"
+ "Mitglieder"
"Du hast nicht gespeicherte Änderungen."
"Änderungen speichern?"
"Thema hinzufügen"
@@ -76,7 +80,7 @@
"Gesperrt"
"Mitglieder"
"Ausstehend"
- "%1$s wird entfernt"
+ "%1$s wird entfernt."
"Administrator"
"Moderator"
"Raummitglieder"
diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
index c88514f68b..005531b3db 100644
--- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
@@ -29,7 +29,12 @@
"Rétrograder"
"Vous ne pourrez pas annuler ce changement car vous vous rétrogradez, si vous êtes le dernier utilisateur privilégié du salon il sera impossible de retrouver les privilèges."
"Vous rétrograder ?"
+ "%1$s (En attente)"
+ "(En attente)"
"Modifier les modérateurs"
+ "Administrateurs"
+ "Modérateurs"
+ "Membres"
"Vous avez des modifications non-enregistrées."
"Enregistrer les modifications?"
"Ajouter un sujet"
diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
index 3640f1e853..66c05c38a7 100644
--- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml
@@ -29,7 +29,11 @@
"Lefokozás"
"Ezt a változtatást nem fogja tudni visszavonni, mivel lefokozza magát, ha Ön az utolsó jogosultságokkal rendelkező felhasználó a szobában, akkor lehetetlen lesz visszaszerezni a jogosultságokat."
"Lefokozza magát?"
+ "%1$s (függőben)"
"Moderátorok szerkesztése"
+ "Rendszergazdák"
+ "Moderátorok"
+ "Tagok"
"Mentetlen módosításai vannak."
"Menti a változtatásokat?"
"Téma hozzáadása"
diff --git a/features/roomdetails/impl/src/main/res/values-in/translations.xml b/features/roomdetails/impl/src/main/res/values-in/translations.xml
index ba3e609d44..ddfd4859ea 100644
--- a/features/roomdetails/impl/src/main/res/values-in/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-in/translations.xml
@@ -29,7 +29,11 @@
"Turunkan"
"Anda tidak akan dapat mengurungkan perubahan ini karena Anda sedang menurunkan Anda sendiri, jika Anda merupakan pengguna dengan hak khusus dalam ruangan maka tidak akan memungkinkan untuk mendapatkan hak tersebut lagi."
"Turunkan Anda sendiri?"
+ "%1$s (Tertunda)"
"Sunting Moderator"
+ "Admin"
+ "Moderator"
+ "Anggota"
"Anda memiliki perubahan yang belum disimpan."
"Simpan perubahan?"
"Tambahkan topik"
diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml
index 02e7f03cba..77b46cdeda 100644
--- a/features/roomdetails/impl/src/main/res/values-it/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml
@@ -30,6 +30,9 @@
"Non potrai annullare questa modifica perché ti stai declassando, se sei l\'ultimo utente privilegiato nella stanza, sarà impossibile riottenere i privilegi."
"Declassare te stesso?"
"Modifica moderatori"
+ "Amministratori"
+ "Moderatori"
+ "Membri"
"Hai delle modifiche non salvate."
"Salvare le modifiche?"
"Aggiungi argomento"
diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
index 787622103b..0f4c19b4d8 100644
--- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
@@ -10,6 +10,7 @@
"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."
"Sondaje"
"Toți"
+ "Membri"
"Adăugare subiect"
"Deja membru"
"Deja invitat"
diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
index 401b4a7c0c..200b2ef3c7 100644
--- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml
@@ -29,7 +29,11 @@
"Понизить уровень"
"Вы не сможете отменить это изменение, так как понижаете себя статус. Если вы являетесь последним привилегированным пользователем в комнате, восстановить привилегии будет невозможно."
"Понизить свой уровень?"
+ "%1$s (Ожидание)"
"Редактировать роль модераторов"
+ "Администраторы"
+ "Модераторы"
+ "Участники"
"У вас есть несохраненные изменения."
"Сохранить изменения?"
"Добавить тему"
diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
index 78d4d842d9..c85b04017c 100644
--- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml
@@ -29,7 +29,11 @@
"Znížiť"
"Túto zmenu nebudete môcť vrátiť späť, pretože znižujete svoju úroveň. Ak ste posledným privilegovaným používateľom v miestnosti, nebude možné získať znova oprávnenia."
"Znížiť svoju úroveň?"
+ "%1$s (Čaká sa)"
"Upraviť moderátorov"
+ "Správcovia"
+ "Moderátori"
+ "Členovia"
"Máte neuložené zmeny."
"Uložiť zmeny?"
"Pridať tému"
diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml
index b42e1a5e63..415334df5a 100644
--- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml
@@ -11,18 +11,18 @@
"Опитування"
"Тільки для адміністраторів"
"Заблоковувати людей"
- "Видалити повідомлення"
+ "Вилучати повідомлення"
"Усі"
- "Запросити людей"
+ "Запрошувати людей"
"Модерація учасників"
"Повідомлення та зміст"
"Адміністратори та модератори"
- "Видалити людей"
+ "Вилучати людей"
"Змінити аватар кімнати"
"Деталі кімнати"
"Змінити назву кімнати"
"Змінити тему кімнати"
- "Надіслати повідомлення"
+ "Надсилати повідомлення"
"Керувати адмінами"
"Ви не зможете скасувати цю дію. Ви просуваєте користувача, щоб він мав такий же рівень прав, як і ви."
"Додати адміністратора?"
@@ -30,6 +30,9 @@
"Ви не зможете скасувати цю зміну, оскільки ви знижуєте себе, якщо ви останній привілейований користувач у кімнаті, відновити привілеї буде неможливо."
"Понизити себе?"
"Керувати модераторами"
+ "Адміністратори"
+ "Модератори"
+ "Учасники"
"У вас є не збережені зміни."
"Зберегти зміни?"
"Додати тему"
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index cde723257f..8067676f93 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -29,7 +29,13 @@
"Demote"
"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."
"Demote yourself?"
+ "%1$s (Pending)"
+ "(Pending)"
+ "Admins automatically have moderator privileges"
"Edit Moderators"
+ "Admins"
+ "Moderators"
+ "Members"
"You have unsaved changes."
"Save changes?"
"Add topic"
@@ -53,6 +59,7 @@
"Room name"
"Security"
"Share room"
+ "Room info"
"Topic"
"Updating room…"
"Ban"
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
index 33f0b3fec0..243b6c0c4d 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
@@ -32,9 +32,6 @@ import io.element.android.features.roomdetails.impl.members.moderation.aRoomMemb
import io.element.android.features.roomdetails.members.moderation.FakeRoomMembersModerationPresenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
-import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@@ -241,14 +238,12 @@ private fun TestScope.createPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixRoom: MatrixRoom = FakeMatrixRoom(),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
moderationPresenter: FakeRoomMembersModerationPresenter = FakeRoomMembersModerationPresenter(),
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator { }
) = RoomMemberListPresenter(
room = matrixRoom,
roomMemberListDataSource = roomMemberListDataSource,
coroutineDispatchers = coroutineDispatchers,
- featureFlagService = featureFlagService,
roomMembersModerationPresenter = moderationPresenter,
navigator = navigator
)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTests.kt
index 8d49eed353..e972c37ae4 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTests.kt
@@ -28,8 +28,6 @@ import io.element.android.features.roomdetails.impl.members.moderation.Moderatio
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-import io.element.android.libraries.featureflag.api.FeatureFlags
-import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@@ -45,13 +43,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultRoomMembersModerationPresenterTests {
- @Test
- fun `canDisplayModerationActions - when feature flag is disabled returns false`() = runTest {
- val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to false))
- val presenter = createDefaultRoomMembersModerationPresenter(featureFlagService = featureFlagService)
- assertThat(presenter.canDisplayModerationActions()).isFalse()
- }
-
@Test
fun `canDisplayModerationActions - when room is DM is false`() = runTest {
val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply {
@@ -309,13 +300,11 @@ class DefaultRoomMembersModerationPresenterTests {
private fun TestScope.createDefaultRoomMembersModerationPresenter(
matrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
- featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): DefaultRoomMembersModerationPresenter {
return DefaultRoomMembersModerationPresenter(
room = matrixRoom,
- featureFlagService = featureFlagService,
dispatchers = dispatchers,
analyticsService = analyticsService,
)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTests.kt
index ff5e185bbd..e8ff232222 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTests.kt
@@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
-import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesPresenter
@@ -30,6 +29,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@@ -106,15 +106,19 @@ class ChangeRolesPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
- val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
- assertThat(initialResults).hasSize(10)
+ val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
+ assertThat(initialResults?.members).hasSize(8)
+ assertThat(initialResults?.moderators).hasSize(1)
+ assertThat(initialResults?.admins).hasSize(1)
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
skipItems(1)
- val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
- assertThat(searchResults).hasSize(1)
- assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
+ val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
+ assertThat(searchResults?.admins).hasSize(1)
+ assertThat(searchResults?.moderators).isEmpty()
+ assertThat(searchResults?.members).isEmpty()
+ assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
}
}
@@ -128,15 +132,19 @@ class ChangeRolesPresenterTests {
presenter.present()
}.test {
skipItems(1)
- val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
- assertThat(initialResults).hasSize(10)
+ val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
+ assertThat(initialResults?.members).hasSize(8)
+ assertThat(initialResults?.moderators).hasSize(1)
+ assertThat(initialResults?.admins).hasSize(1)
room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList()))
skipItems(1)
- val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
- assertThat(searchResults).hasSize(1)
- assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
+ val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
+ assertThat(searchResults?.admins).hasSize(1)
+ assertThat(searchResults?.moderators).isEmpty()
+ assertThat(searchResults?.members).isEmpty()
+ assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
}
}
@@ -154,10 +162,10 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
assertThat(awaitItem().selectedUsers).hasSize(2)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
assertThat(awaitItem().selectedUsers).hasSize(1)
}
}
@@ -177,13 +185,13 @@ class ChangeRolesPresenterTests {
assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
with(awaitItem()) {
assertThat(selectedUsers).hasSize(2)
assertThat(hasPendingChanges).isTrue()
}
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
with(awaitItem()) {
assertThat(selectedUsers).hasSize(1)
assertThat(hasPendingChanges).isFalse()
@@ -226,7 +234,7 @@ class ChangeRolesPresenterTests {
assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Exit)
val confirmingState = awaitItem()
@@ -252,7 +260,7 @@ class ChangeRolesPresenterTests {
assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
val updatedState = awaitItem()
assertThat(updatedState.hasPendingChanges).isTrue()
skipItems(1)
@@ -279,8 +287,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
-
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save)
val confirmingState = awaitItem()
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.Confirming)
@@ -304,7 +311,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save)
val confirmingState = awaitItem()
@@ -334,7 +341,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
@@ -357,7 +364,7 @@ class ChangeRolesPresenterTests {
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
- initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
+ initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save)
val failedState = awaitItem()
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt
new file mode 100644
index 0000000000..93cbad4d58
--- /dev/null
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt
@@ -0,0 +1,309 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.rolesandpermissions.changeroles
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent
+import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesState
+import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesView
+import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.aChangeRolesState
+import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.aChangeRolesStateWithSelectedUsers
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.toMatrixUser
+import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import kotlinx.collections.immutable.toImmutableList
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import java.lang.IllegalStateException
+
+@RunWith(AndroidJUnit4::class)
+class ChangeRolesViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `passing a 'USER' role throws an exception`() {
+ val exception = runCatching {
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ role = RoomMember.Role.USER,
+ eventSink = EnsureNeverCalledWithParam(),
+ ),
+ )
+ }.exceptionOrNull()
+
+ assertThat(exception).isNotNull()
+ }
+
+ @Test
+ fun `back key - with search active toggles the search`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ isSearchActive = true,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.pressBackKey()
+
+ eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
+ }
+
+ @Test
+ fun `back key - with search inactive exits the screen`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ isSearchActive = false,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.pressBackKey()
+
+ eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
+ }
+
+ @Test
+ fun `back button - exits the screen`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ isSearchActive = false,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.pressBack()
+
+ eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
+ }
+
+ @Test
+ fun `save button - with changes, it saves them`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ hasPendingChanges = true,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.clickOn(CommonStrings.action_save)
+
+ eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save))
+ }
+
+ @Test
+ fun `save button - with no changes, does nothing`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ hasPendingChanges = false,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.clickOn(CommonStrings.action_save)
+
+ eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged("")))
+ }
+
+ @Test
+ fun `exit confirmation dialog - submit exits the screen`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ isSearchActive = true,
+ exitState = AsyncAction.Confirming,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.clickOn(CommonStrings.action_ok)
+
+ eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
+ }
+
+ @Test
+ fun `exit confirmation dialog - cancel removes the dialog`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ isSearchActive = true,
+ exitState = AsyncAction.Confirming,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.clickOn(CommonStrings.action_cancel)
+
+ eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit)
+ }
+
+ @Test
+ fun `save confirmation dialog - submit saves the changes`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ role = RoomMember.Role.ADMIN,
+ isSearchActive = true,
+ savingState = AsyncAction.Confirming,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.clickOn(CommonStrings.action_ok)
+
+ eventsRecorder.assertSingle(ChangeRolesEvent.Save)
+ }
+
+ @Test
+ fun `save confirmation dialog - cancel removes the dialog`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ role = RoomMember.Role.ADMIN,
+ isSearchActive = true,
+ savingState = AsyncAction.Confirming,
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.clickOn(CommonStrings.action_cancel)
+
+ eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
+ }
+
+ @Test
+ fun `error dialog - dismissing removes the dialog`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setChangeRolesContent(
+ state = aChangeRolesState(
+ isSearchActive = true,
+ savingState = AsyncAction.Failure(IllegalStateException("boom")),
+ eventSink = eventsRecorder,
+ ),
+ )
+
+ rule.clickOn(CommonStrings.action_ok)
+
+ eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
+ }
+
+ @Test
+ fun `testing removing user from selected list emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ val selectedUsers = aMatrixUserList().take(2)
+ val userToDeselect = selectedUsers[1]
+ assertThat(userToDeselect.displayName).isEqualTo("Bob")
+ rule.setChangeRolesContent(
+ state = aChangeRolesStateWithSelectedUsers().copy(
+ selectedUsers = selectedUsers.toImmutableList(),
+ eventSink = eventsRecorder,
+ ),
+ )
+ // Unselect the user from the row list
+ val contentDescription = rule.activity.getString(CommonStrings.action_remove)
+ rule.onNodeWithContentDescription(contentDescription).performClick()
+ eventsRecorder.assertList(
+ listOf(
+ ChangeRolesEvent.QueryChanged(""),
+ ChangeRolesEvent.UserSelectionToggled(userToDeselect),
+ )
+ )
+ }
+
+ @Test
+ @Config(qualifiers = "h1000dp")
+ fun `testing adding user to the selected list emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ val selectedUsers = aMatrixUserList().take(2)
+ val state = aChangeRolesStateWithSelectedUsers().copy(
+ selectedUsers = selectedUsers.toImmutableList(),
+ eventSink = eventsRecorder,
+ )
+ val userToSelect = (state.searchResults as SearchBarResultState.Results).results.members.first().toMatrixUser()
+ assertThat(userToSelect.displayName).isEqualTo("Carol")
+ rule.setChangeRolesContent(
+ state = state,
+ )
+ // Select the user from the row list
+ rule.onNodeWithText("Carol").performClick()
+ eventsRecorder.assertList(
+ listOf(
+ ChangeRolesEvent.QueryChanged(""),
+ ChangeRolesEvent.UserSelectionToggled(userToSelect),
+ )
+ )
+ }
+
+ @Test
+ fun `testing removing user to the selected list emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ val selectedUsers = aMatrixUserList().take(2)
+ val state = aChangeRolesStateWithSelectedUsers().copy(
+ selectedUsers = selectedUsers.toImmutableList(),
+ eventSink = eventsRecorder,
+ )
+ val userToSelect = (state.searchResults as SearchBarResultState.Results).results.moderators.first().toMatrixUser()
+ assertThat(userToSelect.displayName).isEqualTo("Bob")
+ rule.setChangeRolesContent(
+ state = state,
+ )
+ // Select the user from the rom list
+ rule.onAllNodesWithText("Bob")[1].performClick()
+ eventsRecorder.assertList(
+ listOf(
+ ChangeRolesEvent.QueryChanged(""),
+ ChangeRolesEvent.UserSelectionToggled(userToSelect),
+ )
+ )
+ }
+
+ private fun AndroidComposeTestRule.setChangeRolesContent(
+ state: ChangeRolesState,
+ onBackPressed: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ChangeRolesView(
+ state = state,
+ navigateUp = onBackPressed,
+ )
+ }
+ }
+}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/MembersByRoleTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/MembersByRoleTest.kt
new file mode 100644
index 0000000000..7819745c52
--- /dev/null
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/MembersByRoleTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdetails.rolesandpermissions.changeroles
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.MembersByRole
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.A_USER_ID_2
+import io.element.android.libraries.matrix.test.A_USER_ID_3
+import io.element.android.libraries.matrix.test.A_USER_ID_4
+import io.element.android.libraries.matrix.test.A_USER_ID_5
+import io.element.android.libraries.matrix.test.room.aRoomMember
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Test
+
+class MembersByRoleTest {
+ @Test
+ fun `constructor - with single member list categorizes and sorts members`() {
+ val members = listOf(
+ aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
+ aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
+ aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
+ aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
+ aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
+ )
+ val membersByRole = MembersByRole(members = members)
+ assertThat(membersByRole.admins).containsExactly(
+ aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
+ aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
+ )
+ assertThat(membersByRole.moderators).isEmpty()
+ assertThat(membersByRole.members).containsExactly(
+ aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
+ aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
+ aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
+ )
+ }
+
+ @Test
+ fun `isEmpty - only returns true with no members of any role`() {
+ val emptyMembersByRole = MembersByRole(emptyList())
+ assertThat(emptyMembersByRole.isEmpty()).isTrue()
+
+ val membersByRoleWithAdmins = MembersByRole(
+ admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.ADMIN)),
+ moderators = persistentListOf(),
+ members = persistentListOf(),
+ )
+ assertThat(membersByRoleWithAdmins.isEmpty()).isFalse()
+
+ val membersByRoleWithModerators = MembersByRole(
+ admins = persistentListOf(),
+ moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.MODERATOR)),
+ members = persistentListOf(),
+ )
+ assertThat(membersByRoleWithModerators.isEmpty()).isFalse()
+
+ val membersByRoleWithMembers = MembersByRole(
+ admins = persistentListOf(),
+ moderators = persistentListOf(),
+ members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.USER)),
+ )
+ assertThat(membersByRoleWithMembers.isEmpty()).isFalse()
+ }
+}
diff --git a/features/roomdirectory/api/build.gradle.kts b/features/roomdirectory/api/build.gradle.kts
new file mode 100644
index 0000000000..04a813bd0f
--- /dev/null
+++ b/features/roomdirectory/api/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.roomdirectory.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.designsystem)
+}
diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt
new file mode 100644
index 0000000000..5b945b2a7d
--- /dev/null
+++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.api
+
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.matrix.api.core.RoomId
+
+data class RoomDescription(
+ val roomId: RoomId,
+ val name: String,
+ val description: String,
+ val avatarData: AvatarData,
+ val canBeJoined: Boolean,
+)
diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt
new file mode 100644
index 0000000000..5a693a4a83
--- /dev/null
+++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.RoomId
+
+interface RoomDirectoryEntryPoint : FeatureEntryPoint {
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onOpenRoom(roomId: RoomId)
+ }
+}
diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts
new file mode 100644
index 0000000000..85bb195da3
--- /dev/null
+++ b/features/roomdirectory/impl/build.gradle.kts
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.roomdirectory.impl"
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ api(projects.features.roomdirectory.api)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.testtags)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.androidx.compose.ui.test.junit)
+ 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.tests.testutils)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt
new file mode 100644
index 0000000000..c15a748a9e
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
+import io.element.android.features.roomdirectory.impl.root.RoomDirectoryNode
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultRoomDirectoryEntryPoint @Inject constructor() : RoomDirectoryEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDirectoryEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : RoomDirectoryEntryPoint.NodeBuilder {
+ override fun callback(callback: RoomDirectoryEntryPoint.Callback): RoomDirectoryEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt
new file mode 100644
index 0000000000..37e0ffb3c6
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+sealed interface RoomDirectoryEvents {
+ data class JoinRoom(val roomId: RoomId) : RoomDirectoryEvents
+ data class Search(val query: String) : RoomDirectoryEvents
+ data object LoadMore : RoomDirectoryEvents
+ data object JoinRoomDismissError : RoomDirectoryEvents
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt
new file mode 100644
index 0000000000..dc3581589e
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+
+@ContributesNode(SessionScope::class)
+class RoomDirectoryNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: RoomDirectoryPresenter,
+) : Node(buildContext, plugins = plugins) {
+ private fun onRoomJoined(roomId: RoomId) {
+ plugins().forEach {
+ it.onOpenRoom(roomId)
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ RoomDirectoryView(
+ state = state,
+ onRoomJoined = ::onRoomJoined,
+ onBackPressed = ::navigateUp,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
new file mode 100644
index 0000000000..5d4cef55cb
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import io.element.android.features.roomdirectory.impl.root.di.JoinRoom
+import io.element.android.features.roomdirectory.impl.root.model.RoomDirectoryListState
+import io.element.android.features.roomdirectory.impl.root.model.toFeatureModel
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runUpdatingState
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class RoomDirectoryPresenter @Inject constructor(
+ private val dispatchers: CoroutineDispatchers,
+ private val joinRoom: JoinRoom,
+ private val roomDirectoryService: RoomDirectoryService,
+) : Presenter {
+ @Composable
+ override fun present(): RoomDirectoryState {
+ var loadingMore by remember {
+ mutableStateOf(false)
+ }
+ var searchQuery by rememberSaveable {
+ mutableStateOf(null)
+ }
+ val coroutineScope = rememberCoroutineScope()
+ val roomDirectoryList = remember {
+ roomDirectoryService.createRoomDirectoryList(coroutineScope)
+ }
+ val listState by roomDirectoryList.collectState()
+ val joinRoomAction: MutableState> = remember {
+ mutableStateOf(AsyncAction.Uninitialized)
+ }
+ LaunchedEffect(searchQuery) {
+ if (searchQuery == null) return@LaunchedEffect
+ // cancel load more right away
+ loadingMore = false
+ // debounce search query
+ delay(300)
+ roomDirectoryList.filter(searchQuery, 20)
+ }
+ LaunchedEffect(loadingMore) {
+ if (loadingMore) {
+ roomDirectoryList.loadMore()
+ loadingMore = false
+ }
+ }
+ fun handleEvents(event: RoomDirectoryEvents) {
+ when (event) {
+ RoomDirectoryEvents.LoadMore -> {
+ loadingMore = true
+ }
+ is RoomDirectoryEvents.Search -> {
+ searchQuery = event.query
+ }
+ is RoomDirectoryEvents.JoinRoom -> {
+ coroutineScope.joinRoom(joinRoomAction, event.roomId)
+ }
+ RoomDirectoryEvents.JoinRoomDismissError -> {
+ joinRoomAction.value = AsyncAction.Uninitialized
+ }
+ }
+ }
+
+ return RoomDirectoryState(
+ query = searchQuery.orEmpty(),
+ roomDescriptions = listState.items,
+ displayLoadMoreIndicator = listState.hasMoreToLoad,
+ joinRoomAction = joinRoomAction.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.joinRoom(state: MutableState>, roomId: RoomId) = launch {
+ state.runUpdatingState {
+ joinRoom(roomId)
+ }
+ }
+
+ @Composable
+ private fun RoomDirectoryList.collectState() = remember {
+ state.map {
+ val items = it.items
+ .map { roomDescription -> roomDescription.toFeatureModel() }
+ .toImmutableList()
+ RoomDirectoryListState(items = items, hasMoreToLoad = it.hasMoreToLoad)
+ }.flowOn(dispatchers.computation)
+ }.collectAsState(RoomDirectoryListState.Default)
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt
new file mode 100644
index 0000000000..526139338d
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.collections.immutable.ImmutableList
+
+data class RoomDirectoryState(
+ val query: String,
+ val roomDescriptions: ImmutableList,
+ val displayLoadMoreIndicator: Boolean,
+ val joinRoomAction: AsyncAction,
+ val eventSink: (RoomDirectoryEvents) -> Unit
+) {
+ val displayEmptyState = roomDescriptions.isEmpty() && !displayLoadMoreIndicator
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt
new file mode 100644
index 0000000000..efb6624260
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+open class RoomDirectoryStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aRoomDirectoryState(),
+ aRoomDirectoryState(
+ query = "Element",
+ roomDescriptions = aRoomDescriptionList(),
+ ),
+ aRoomDirectoryState(
+ query = "Element",
+ roomDescriptions = aRoomDescriptionList(),
+ displayLoadMoreIndicator = true,
+ ),
+ aRoomDirectoryState(
+ query = "Element",
+ roomDescriptions = aRoomDescriptionList(),
+ joinRoomAction = AsyncAction.Loading,
+ ),
+ aRoomDirectoryState(
+ query = "Element",
+ roomDescriptions = aRoomDescriptionList(),
+ joinRoomAction = AsyncAction.Failure(Exception("Failed to join room")),
+ ),
+ )
+}
+
+fun aRoomDirectoryState(
+ query: String = "",
+ displayLoadMoreIndicator: Boolean = false,
+ roomDescriptions: ImmutableList = persistentListOf(),
+ joinRoomAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (RoomDirectoryEvents) -> Unit = {},
+) = RoomDirectoryState(
+ query = query,
+ roomDescriptions = roomDescriptions,
+ displayLoadMoreIndicator = displayLoadMoreIndicator,
+ joinRoomAction = joinRoomAction,
+ eventSink = eventSink,
+)
+
+fun aRoomDescriptionList(): ImmutableList {
+ return persistentListOf(
+ RoomDescription(
+ roomId = RoomId("!exa:matrix.org"),
+ name = "Element X Android",
+ description = "Element X is a secure, private and decentralized messenger.",
+ avatarData = AvatarData(
+ id = "!exa:matrix.org",
+ name = "Element X Android",
+ url = null,
+ size = AvatarSize.RoomDirectoryItem
+ ),
+ canBeJoined = true,
+ ),
+ RoomDescription(
+ roomId = RoomId("!exi:matrix.org"),
+ name = "Element X iOS",
+ description = "Element X is a secure, private and decentralized messenger.",
+ avatarData = AvatarData(
+ id = "!exi:matrix.org",
+ name = "Element X iOS",
+ url = null,
+ size = AvatarSize.RoomDirectoryItem
+ ),
+ canBeJoined = false,
+ )
+ )
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt
new file mode 100644
index 0000000000..f6188c3269
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt
@@ -0,0 +1,321 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TextFieldColors
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.features.roomdirectory.impl.R
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextField
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+fun RoomDirectoryView(
+ state: RoomDirectoryState,
+ onRoomJoined: (RoomId) -> Unit,
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ fun joinRoom(roomId: RoomId) {
+ state.eventSink(RoomDirectoryEvents.JoinRoom(roomId))
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ RoomDirectoryTopBar(onBackPressed = onBackPressed)
+ },
+ content = { padding ->
+ RoomDirectoryContent(
+ state = state,
+ onResultClicked = ::joinRoom,
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ )
+ }
+ )
+ AsyncActionView(
+ async = state.joinRoomAction,
+ onSuccess = onRoomJoined,
+ onErrorDismiss = {
+ state.eventSink(RoomDirectoryEvents.JoinRoomDismissError)
+ },
+ errorMessage = {
+ stringResource(id = CommonStrings.error_unknown)
+ }
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RoomDirectoryTopBar(
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ TopAppBar(
+ modifier = modifier,
+ navigationIcon = {
+ BackButton(onClick = onBackPressed)
+ },
+ title = {
+ Text(
+ text = stringResource(id = R.string.screen_room_directory_search_title),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ }
+ )
+}
+
+@Composable
+private fun RoomDirectoryContent(
+ state: RoomDirectoryState,
+ onResultClicked: (RoomId) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ SearchTextField(
+ query = state.query,
+ onQueryChange = { state.eventSink(RoomDirectoryEvents.Search(it)) },
+ placeholder = stringResource(id = CommonStrings.action_search),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ RoomDirectoryRoomList(
+ roomDescriptions = state.roomDescriptions,
+ displayLoadMoreIndicator = state.displayLoadMoreIndicator,
+ displayEmptyState = state.displayEmptyState,
+ onResultClicked = onResultClicked,
+ onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) },
+ )
+ }
+}
+
+@Composable
+private fun RoomDirectoryRoomList(
+ roomDescriptions: ImmutableList,
+ displayLoadMoreIndicator: Boolean,
+ displayEmptyState: Boolean,
+ onResultClicked: (RoomId) -> Unit,
+ onReachedLoadMore: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(modifier = modifier) {
+ items(roomDescriptions) { roomDescription ->
+ RoomDirectoryRoomRow(
+ roomDescription = roomDescription,
+ onClick = onResultClicked,
+ )
+ }
+ if (displayEmptyState) {
+ item {
+ Text(
+ text = stringResource(id = CommonStrings.common_no_results),
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = ElementTheme.colors.textPlaceholder,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+ if (displayLoadMoreIndicator) {
+ item {
+ LoadMoreIndicator(modifier = Modifier.fillMaxWidth())
+ LaunchedEffect(onReachedLoadMore) {
+ onReachedLoadMore()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadMoreIndicator(modifier: Modifier = Modifier) {
+ Box(
+ modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(
+ strokeWidth = 2.dp,
+ )
+ }
+}
+
+@Composable
+private fun SearchTextField(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ placeholder: String,
+ modifier: Modifier = Modifier,
+ colors: TextFieldColors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ unfocusedPlaceholderColor = ElementTheme.colors.textPlaceholder,
+ focusedPlaceholderColor = ElementTheme.colors.textPlaceholder,
+ focusedTextColor = ElementTheme.colors.textPrimary,
+ unfocusedTextColor = ElementTheme.colors.textPrimary,
+ focusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary,
+ unfocusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary,
+ ),
+) {
+ val focusManager = LocalFocusManager.current
+ TextField(
+ modifier = modifier.testTag(TestTags.searchTextField.value),
+ textStyle = ElementTheme.typography.fontBodyLgRegular,
+ singleLine = true,
+ value = query,
+ onValueChange = onQueryChange,
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ focusManager.clearFocus()
+ }
+ ),
+ colors = colors,
+ placeholder = { Text(placeholder) },
+ trailingIcon = {
+ if (query.isNotEmpty()) {
+ IconButton(
+ onClick = {
+ onQueryChange("")
+ }
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Close(),
+ contentDescription = stringResource(CommonStrings.action_clear),
+ )
+ }
+ } else {
+ Icon(
+ imageVector = CompoundIcons.Search(),
+ contentDescription = stringResource(CommonStrings.action_search),
+ )
+ }
+ },
+ )
+}
+
+@Composable
+private fun RoomDirectoryRoomRow(
+ roomDescription: RoomDescription,
+ onClick: (RoomId) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(enabled = roomDescription.canBeJoined) {
+ onClick(roomDescription.roomId)
+ }
+ .padding(
+ top = 12.dp,
+ bottom = 12.dp,
+ start = 16.dp,
+ )
+ .height(IntrinsicSize.Min),
+ ) {
+ Avatar(
+ avatarData = roomDescription.avatarData,
+ modifier = Modifier.align(Alignment.CenterVertically)
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp)
+ ) {
+ Text(
+ text = roomDescription.name,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = ElementTheme.colors.textPrimary,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = roomDescription.description,
+ maxLines = 1,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (roomDescription.canBeJoined) {
+ Text(
+ text = stringResource(id = CommonStrings.action_join),
+ color = ElementTheme.colors.textSuccessPrimary,
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .padding(start = 4.dp, end = 12.dp)
+ )
+ } else {
+ Spacer(modifier = Modifier.width(24.dp))
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview {
+ RoomDirectoryView(
+ state = state,
+ onRoomJoined = {},
+ onBackPressed = {},
+ )
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt
new file mode 100644
index 0000000000..983d2a1dd2
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root.di
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import javax.inject.Inject
+
+interface JoinRoom {
+ suspend operator fun invoke(roomId: RoomId): Result
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultJoinRoom @Inject constructor(private val client: MatrixClient) : JoinRoom {
+ override suspend fun invoke(roomId: RoomId) = client.joinRoom(roomId)
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt
new file mode 100644
index 0000000000..be36eb5053
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root.model
+
+import io.element.android.features.roomdirectory.api.RoomDescription
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription as MatrixRoomDescription
+
+fun MatrixRoomDescription.toFeatureModel(): RoomDescription {
+ fun name(): String {
+ return name ?: alias ?: roomId.value
+ }
+
+ fun description(): String {
+ val topic = topic
+ val alias = alias
+ val name = name
+ return when {
+ topic != null -> topic
+ name != null && alias != null -> alias
+ name == null && alias == null -> ""
+ else -> roomId.value
+ }
+ }
+
+ return RoomDescription(
+ roomId = roomId,
+ name = name(),
+ description = description(),
+ avatarData = AvatarData(
+ id = roomId.value,
+ name = name,
+ url = avatarUrl,
+ size = AvatarSize.RoomDirectoryItem,
+ ),
+ canBeJoined = joinRule == MatrixRoomDescription.JoinRule.PUBLIC,
+ )
+}
diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt
new file mode 100644
index 0000000000..60f344f67b
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root.model
+
+import io.element.android.features.roomdirectory.api.RoomDescription
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+internal data class RoomDirectoryListState(
+ val hasMoreToLoad: Boolean,
+ val items: ImmutableList,
+) {
+ companion object {
+ val Default = RoomDirectoryListState(
+ hasMoreToLoad = true,
+ items = persistentListOf()
+ )
+ }
+}
diff --git a/features/roomdirectory/impl/src/main/res/values-be/translations.xml b/features/roomdirectory/impl/src/main/res/values-be/translations.xml
new file mode 100644
index 0000000000..16b52ab04d
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-be/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Памылка загрузкі"
+ "Каталог пакояў"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-cs/translations.xml b/features/roomdirectory/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..49ae689e43
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Načítání se nezdařilo"
+ "Adresář místností"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-de/translations.xml b/features/roomdirectory/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..8c60d845b2
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Fehler beim Laden"
+ "Raumverzeichnis"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-fr/translations.xml b/features/roomdirectory/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..25c8064237
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Échec du chargement"
+ "Annuaire des salons"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-hu/translations.xml b/features/roomdirectory/impl/src/main/res/values-hu/translations.xml
new file mode 100644
index 0000000000..fcaa3eb1bf
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-hu/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Sikertelen betöltés"
+ "Szobakatalógus"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-ru/translations.xml b/features/roomdirectory/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..92e16fadaa
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Сбой загрузки"
+ "Каталог комнат"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-sk/translations.xml b/features/roomdirectory/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..9d4e0bfd61
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Načítanie zlyhalo"
+ "Adresár miestností"
+
diff --git a/features/roomdirectory/impl/src/main/res/values-uk/translations.xml b/features/roomdirectory/impl/src/main/res/values-uk/translations.xml
new file mode 100644
index 0000000000..4201f040e9
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Не вдалося завантажити"
+ "Каталог кімнат"
+
diff --git a/features/roomdirectory/impl/src/main/res/values/localazy.xml b/features/roomdirectory/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..d3fb9d15ae
--- /dev/null
+++ b/features/roomdirectory/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,5 @@
+
+
+ "Failed loading"
+ "Room directory"
+
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt
new file mode 100644
index 0000000000..3f4d17aefd
--- /dev/null
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import io.element.android.features.roomdirectory.impl.root.di.JoinRoom
+import io.element.android.libraries.matrix.api.core.RoomId
+
+class FakeJoinRoom(
+ var lambda: (RoomId) -> Result = { Result.success(it) }
+) : JoinRoom {
+ override suspend fun invoke(roomId: RoomId) = lambda(roomId)
+}
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
new file mode 100644
index 0000000000..eefafc86e1
--- /dev/null
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.roomdirectory.impl.root.di.JoinRoom
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryList
+import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService
+import io.element.android.libraries.matrix.test.roomdirectory.aRoomDescription
+import io.element.android.tests.testutils.lambda.any
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class) class RoomDirectoryPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createRoomDirectoryPresenter()
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.query).isEmpty()
+ assertThat(initialState.displayEmptyState).isFalse()
+ assertThat(initialState.joinRoomAction).isEqualTo(AsyncAction.Uninitialized)
+ assertThat(initialState.roomDescriptions).isEmpty()
+ assertThat(initialState.displayLoadMoreIndicator).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - room directory list emits empty state`() = runTest {
+ val directoryListStateFlow = MutableSharedFlow(replay = 1)
+ val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow)
+ val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+ val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+ presenter.test {
+ skipItems(1)
+ directoryListStateFlow.emit(
+ RoomDirectoryList.State(false, emptyList())
+ )
+ awaitItem().also { state ->
+ assertThat(state.displayEmptyState).isTrue()
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - room directory list emits non-empty state`() = runTest {
+ val directoryListStateFlow = MutableSharedFlow(replay = 1)
+ val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow)
+ val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+ val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+ presenter.test {
+ skipItems(1)
+ directoryListStateFlow.emit(
+ RoomDirectoryList.State(
+ hasMoreToLoad = true,
+ items = listOf(aRoomDescription())
+ )
+ )
+ awaitItem().also { state ->
+ assertThat(state.displayEmptyState).isFalse()
+ assertThat(state.roomDescriptions).hasSize(1)
+ }
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - emit search event`() = runTest {
+ val filterLambda = lambdaRecorder { _: String?, _: Int ->
+ Result.success(Unit)
+ }
+ val roomDirectoryList = FakeRoomDirectoryList(filterLambda = filterLambda)
+ val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+ val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+ presenter.test {
+ awaitItem().also { state ->
+ state.eventSink(RoomDirectoryEvents.Search("test"))
+ }
+ awaitItem().also { state ->
+ assertThat(state.query).isEqualTo("test")
+ }
+ advanceUntilIdle()
+ cancelAndIgnoreRemainingEvents()
+ }
+ assert(filterLambda)
+ .isCalledOnce()
+ .with(value("test"), any())
+ }
+
+ @Test
+ fun `present - emit load more event`() = runTest {
+ val loadMoreLambda = lambdaRecorder { ->
+ Result.success(Unit)
+ }
+ val roomDirectoryList = FakeRoomDirectoryList(loadMoreLambda = loadMoreLambda)
+ val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList }
+ val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService)
+ presenter.test {
+ awaitItem().also { state ->
+ state.eventSink(RoomDirectoryEvents.LoadMore)
+ }
+ advanceUntilIdle()
+ cancelAndIgnoreRemainingEvents()
+ }
+ assert(loadMoreLambda)
+ .isCalledOnce()
+ .withNoParameter()
+ }
+
+ @Test
+ fun `present - emit join room event`() = runTest {
+ val joinRoomSuccess = lambdaRecorder { roomId: RoomId ->
+ Result.success(roomId)
+ }
+ val joinRoomFailure = lambdaRecorder { roomId: RoomId ->
+ Result.failure(RuntimeException("Failed to join room $roomId"))
+ }
+ val fakeJoinRoom = FakeJoinRoom(joinRoomSuccess)
+ val presenter = createRoomDirectoryPresenter(joinRoom = fakeJoinRoom)
+ presenter.test {
+ awaitItem().also { state ->
+ state.eventSink(RoomDirectoryEvents.JoinRoom(A_ROOM_ID))
+ }
+ awaitItem().also { state ->
+ assertThat(state.joinRoomAction).isEqualTo(AsyncAction.Success(A_ROOM_ID))
+ fakeJoinRoom.lambda = joinRoomFailure
+ state.eventSink(RoomDirectoryEvents.JoinRoom(A_ROOM_ID))
+ }
+ awaitItem().also { state ->
+ assertThat(state.joinRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
+ }
+ }
+ assert(joinRoomSuccess)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID))
+ assert(joinRoomFailure)
+ .isCalledOnce()
+ .with(value(A_ROOM_ID))
+ }
+
+ private fun TestScope.createRoomDirectoryPresenter(
+ roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(
+ createRoomDirectoryListFactory = { FakeRoomDirectoryList() }
+ ),
+ joinRoom: JoinRoom = FakeJoinRoom { Result.success(it) },
+ ): RoomDirectoryPresenter {
+ return RoomDirectoryPresenter(
+ dispatchers = testCoroutineDispatchers(),
+ joinRoom = joinRoom,
+ roomDirectoryService = roomDirectoryService,
+ )
+ }
+}
diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt
new file mode 100644
index 0000000000..bcac35fc3a
--- /dev/null
+++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.roomdirectory.impl.root
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.ensureCalledOnceWithParam
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RoomDirectoryViewTest {
+ @get:Rule val rule = createAndroidComposeRule()
+
+ @Test
+ fun `typing text in search field emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setRoomDirectoryView(
+ state = aRoomDirectoryState(
+ eventSink = eventsRecorder,
+ )
+ )
+ rule.onNodeWithTag(TestTags.searchTextField.value).performTextInput(
+ text = "Test"
+ )
+ eventsRecorder.assertSingle(RoomDirectoryEvents.Search("Test"))
+ }
+
+ @Test
+ fun `clicking on room item emits the expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ val state = aRoomDirectoryState(
+ roomDescriptions = aRoomDescriptionList(),
+ eventSink = eventsRecorder,
+ )
+ rule.setRoomDirectoryView(state = state)
+ val clickedRoom = state.roomDescriptions.first()
+ rule.onNodeWithText(clickedRoom.name).performClick()
+ eventsRecorder.assertSingle(RoomDirectoryEvents.JoinRoom(clickedRoom.roomId))
+ }
+
+ @Test
+ fun `composing load more indicator emits expected Event`() {
+ val eventsRecorder = EventsRecorder()
+ val state = aRoomDirectoryState(
+ displayLoadMoreIndicator = true,
+ eventSink = eventsRecorder,
+ )
+ rule.setRoomDirectoryView(state = state)
+ eventsRecorder.assertSingle(RoomDirectoryEvents.LoadMore)
+ }
+
+ @Test
+ fun `when joining room with success then onRoomJoined lambda is called once`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ val roomDescriptions = aRoomDescriptionList()
+ val joinedRoomId = roomDescriptions.first().roomId
+ val state = aRoomDirectoryState(
+ joinRoomAction = AsyncAction.Success(joinedRoomId),
+ roomDescriptions = roomDescriptions,
+ eventSink = eventsRecorder,
+ )
+ ensureCalledOnceWithParam(joinedRoomId) { callback ->
+ rule.setRoomDirectoryView(
+ state = state,
+ onRoomJoined = callback,
+ )
+ }
+ }
+}
+
+private fun AndroidComposeTestRule.setRoomDirectoryView(
+ state: RoomDirectoryState,
+ onBackPressed: () -> Unit = EnsureNeverCalled(),
+ onRoomJoined: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
+) {
+ setContent {
+ RoomDirectoryView(
+ state = state,
+ onRoomJoined = onRoomJoined,
+ onBackPressed = onBackPressed,
+ )
+ }
+}
diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
index 0404ce18fc..b5d1c1299f 100644
--- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
+++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt
@@ -33,10 +33,10 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onRoomClicked(roomId: RoomId)
fun onCreateRoomClicked()
fun onSettingsClicked()
- fun onSessionVerificationClicked()
fun onSessionConfirmRecoveryKeyClicked()
fun onInvitesClicked()
fun onRoomSettingsClicked(roomId: RoomId)
fun onReportBugClicked()
+ fun onRoomDirectorySearchClicked()
}
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
index 5dec53e158..e9af66d331 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt
@@ -64,10 +64,6 @@ class RoomListNode @AssistedInject constructor(
plugins().forEach { it.onCreateRoomClicked() }
}
- private fun onSessionVerificationClicked() {
- plugins().forEach { it.onSessionVerificationClicked() }
- }
-
private fun onSessionConfirmRecoveryKeyClicked() {
plugins().forEach { it.onSessionConfirmRecoveryKeyClicked() }
}
@@ -91,6 +87,10 @@ class RoomListNode @AssistedInject constructor(
}
}
+ private fun onRoomDirectorySearchClicked() {
+ plugins().forEach { it.onRoomDirectorySearchClicked() }
+ }
+
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -100,11 +100,11 @@ class RoomListNode @AssistedInject constructor(
onRoomClicked = this::onRoomClicked,
onSettingsClicked = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked,
- onVerifyClicked = this::onSessionVerificationClicked,
onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked,
onInvitesClicked = this::onInvitesClicked,
onRoomSettingsClicked = this::onRoomSettingsClicked,
onMenuActionClicked = { onMenuActionClicked(activity, it) },
+ onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked,
modifier = modifier,
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
index 900e640981..0b5d07dc69 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
@@ -58,7 +58,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
-import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
@@ -92,7 +91,6 @@ class RoomListPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
) : Presenter {
private val encryptionService: EncryptionService = client.encryptionService()
- private val sessionVerificationService: SessionVerificationService = client.sessionVerificationService()
private val syncService: SyncService = client.syncService()
@Composable
@@ -159,19 +157,12 @@ class RoomListPresenter @Inject constructor(
securityBannerDismissed: Boolean,
): State {
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
- val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
- val isLastDevice by encryptionService.isLastDevice.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val syncState by syncService.syncState.collectAsState()
return remember {
derivedStateOf {
when {
currentSecurityBannerDismissed -> SecurityBannerState.None
- canVerifySession -> if (isLastDevice) {
- SecurityBannerState.RecoveryKeyConfirmation
- } else {
- SecurityBannerState.SessionVerification
- }
recoveryState == RecoveryState.INCOMPLETE &&
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
else -> SecurityBannerState.None
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
index 250f1c8e87..62f59b4eaa 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
@@ -63,7 +63,6 @@ enum class InvitesState {
enum class SecurityBannerState {
None,
- SessionVerification,
RecoveryKeyConfirmation,
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
index 00f6833f02..c80430f9fe 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
@@ -45,7 +45,6 @@ open class RoomListStateProvider : PreviewParameterProvider {
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
- aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
index 23a105f143..881d06411e 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
@@ -53,12 +53,12 @@ fun RoomListView(
state: RoomListState,
onRoomClicked: (RoomId) -> Unit,
onSettingsClicked: () -> Unit,
- onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit,
+ onRoomDirectorySearchClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
ConnectivityIndicatorContainer(
@@ -85,7 +85,6 @@ fun RoomListView(
RoomListScaffold(
modifier = Modifier.padding(top = topPadding),
state = state,
- onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
@@ -99,6 +98,7 @@ fun RoomListView(
state = state.searchState,
onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
+ onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
modifier = Modifier
.statusBarsPadding()
.padding(top = topPadding)
@@ -113,7 +113,6 @@ fun RoomListView(
@Composable
private fun RoomListScaffold(
state: RoomListState,
- onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@@ -152,7 +151,6 @@ private fun RoomListScaffold(
contentState = state.contentState,
filtersState = state.filtersState,
eventSink = state.eventSink,
- onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = ::onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
@@ -191,11 +189,11 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
state = state,
onRoomClicked = {},
onSettingsClicked = {},
- onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {},
onInvitesClicked = {},
onRoomSettingsClicked = {},
onMenuActionClicked = {},
+ onRoomDirectorySearchClicked = {},
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt
deleted file mode 100644
index d3ad6db206..0000000000
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.features.roomlist.impl.components
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import io.element.android.features.roomlist.impl.R
-import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-
-@Composable
-internal fun RequestVerificationHeader(
- onVerifyClicked: () -> Unit,
- onDismissClicked: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- DialogLikeBannerMolecule(
- modifier = modifier,
- title = stringResource(R.string.session_verification_banner_title),
- content = stringResource(R.string.session_verification_banner_message),
- onSubmitClicked = onVerifyClicked,
- onDismissClicked = onDismissClicked,
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun RequestVerificationHeaderPreview() = ElementPreview {
- RequestVerificationHeader(
- onVerifyClicked = {},
- onDismissClicked = {},
- )
-}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
index 1bb5af4e7a..552ff008a0 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt
@@ -73,7 +73,6 @@ fun RoomListContentView(
contentState: RoomListContentState,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
- onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@@ -103,7 +102,6 @@ fun RoomListContentView(
state = contentState,
filtersState = filtersState,
eventSink = eventSink,
- onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
@@ -161,7 +159,6 @@ private fun RoomsView(
state: RoomListContentState.Rooms,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
- onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@@ -177,7 +174,6 @@ private fun RoomsView(
RoomsViewList(
state = state,
eventSink = eventSink,
- onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
@@ -191,7 +187,6 @@ private fun RoomsView(
private fun RoomsViewList(
state: RoomListContentState.Rooms,
eventSink: (RoomListEvents) -> Unit,
- onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@@ -222,14 +217,6 @@ private fun RoomsViewList(
contentPadding = PaddingValues(bottom = 80.dp)
) {
when (state.securityBannerState) {
- SecurityBannerState.SessionVerification -> {
- item {
- RequestVerificationHeader(
- onVerifyClicked = onVerifyClicked,
- onDismissClicked = { eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
- )
- }
- }
SecurityBannerState.RecoveryKeyConfirmation -> {
item {
ConfirmRecoveryKeyBanner(
@@ -316,10 +303,10 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
),
eventSink = {},
- onVerifyClicked = { },
- onConfirmRecoveryKeyClicked = { },
+ onConfirmRecoveryKeyClicked = {},
onRoomClicked = {},
onRoomLongClicked = {},
- onCreateRoomClicked = { },
- onInvitesClicked = { })
+ onCreateRoomClicked = {},
+ onInvitesClicked = {}
+ )
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
index 78bcda07f1..4ae7f091fe 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenter.kt
@@ -21,21 +21,25 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject
class RoomListSearchPresenter @Inject constructor(
private val dataSource: RoomListSearchDataSource,
+ private val featureFlagService: FeatureFlagService,
) : Presenter {
@Composable
override fun present(): RoomListSearchState {
- var isSearchActive by rememberSaveable {
+ // Do not use rememberSaveable so that search is not active when the user navigates back to the screen
+ var isSearchActive by remember {
mutableStateOf(false)
}
- var searchQuery by rememberSaveable {
+ var searchQuery by remember {
mutableStateOf("")
}
@@ -62,12 +66,14 @@ class RoomListSearchPresenter @Inject constructor(
}
}
+ val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf())
return RoomListSearchState(
isSearchActive = isSearchActive,
query = searchQuery,
results = searchResults,
+ isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = ::handleEvents
)
}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt
index c4b24dc798..92e70ad039 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchState.kt
@@ -23,5 +23,8 @@ data class RoomListSearchState(
val isSearchActive: Boolean,
val query: String,
val results: ImmutableList,
+ val isRoomDirectorySearchEnabled: Boolean,
val eventSink: (RoomListSearchEvents) -> Unit
-)
+) {
+ val displayRoomDirectorySearch = query.isEmpty() && isRoomDirectorySearchEnabled
+}
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt
index ae722a4b04..c4dcbab1ec 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchStateProvider.kt
@@ -26,6 +26,7 @@ class RoomListSearchStateProvider : PreviewParameterProvider
get() = sequenceOf(
aRoomListSearchState(),
+ aRoomListSearchState(isRoomDirectorySearchEnabled = true),
aRoomListSearchState(
isSearchActive = true,
query = "Test",
@@ -38,10 +39,12 @@ fun aRoomListSearchState(
isSearchActive: Boolean = false,
query: String = "",
results: ImmutableList = persistentListOf(),
+ isRoomDirectorySearchEnabled: Boolean = false,
eventSink: (RoomListSearchEvents) -> Unit = { },
) = RoomListSearchState(
isSearchActive = isSearchActive,
query = query,
results = results,
+ isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled,
eventSink = eventSink,
)
diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
index eff6449449..80657ed4fc 100644
--- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
+++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt
@@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@@ -50,8 +51,10 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -64,6 +67,7 @@ internal fun RoomListSearchView(
state: RoomListSearchState,
onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
+ onRoomDirectorySearchClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = state.isSearchActive) {
@@ -87,6 +91,7 @@ internal fun RoomListSearchView(
state = state,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
+ onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
)
}
}
@@ -99,6 +104,7 @@ private fun RoomListSearchContent(
state: RoomListSearchState,
onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
+ onRoomDirectorySearchClicked: () -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
@@ -169,6 +175,14 @@ private fun RoomListSearchContent(
.padding(padding)
.consumeWindowInsets(padding)
) {
+ if (state.displayRoomDirectorySearch) {
+ RoomDirectorySearchButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp, horizontal = 16.dp),
+ onClick = onRoomDirectorySearchClicked
+ )
+ }
LazyColumn(
modifier = Modifier.weight(1f),
) {
@@ -187,12 +201,26 @@ private fun RoomListSearchContent(
}
}
+@Composable
+private fun RoomDirectorySearchButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Button(
+ text = stringResource(id = R.string.screen_roomlist_room_directory_button_title),
+ leadingIcon = IconSource.Vector(CompoundIcons.ListBulleted()),
+ onClick = onClick,
+ modifier = modifier,
+ )
+}
+
@PreviewsDayNight
@Composable
internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
RoomListSearchContent(
state = state,
onRoomClicked = {},
- onRoomLongClicked = {}
+ onRoomLongClicked = {},
+ onRoomDirectorySearchClicked = {},
)
}
diff --git a/features/roomlist/impl/src/main/res/values-be/translations.xml b/features/roomlist/impl/src/main/res/values-be/translations.xml
index e229e68f26..39cbd31bc6 100644
--- a/features/roomlist/impl/src/main/res/values-be/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-be/translations.xml
@@ -1,7 +1,7 @@
"Ваша рэзервовая копія чата зараз не сінхранізавана. Вам трэба пацвердзіць ключ аднаўлення, каб захаваць доступ да рэзервовай копіі чата."
- "Пацвердзіце ключ аднаўлення"
+ "Увядзіце ключ аднаўлення"
"Гэта аднаразовы працэс, дзякуем за чаканне."
"Налада ўліковага запісу."
"Стварыце новую размову або пакой"
@@ -14,7 +14,7 @@
"Нізкі прыярытэт"
"Вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."
"У вас няма чатаў для гэтай катэгорыі"
- "Людзі"
+ "Удзельнікі"
"У вас пакуль няма асабістых паведамленняў"
"Пакоі"
"Вас пакуль няма ў ніводным пакоі"
diff --git a/features/roomlist/impl/src/main/res/values-hu/translations.xml b/features/roomlist/impl/src/main/res/values-hu/translations.xml
index 16ba862467..559a481246 100644
--- a/features/roomlist/impl/src/main/res/values-hu/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-hu/translations.xml
@@ -1,6 +1,6 @@
- "A csevegés biztonsági mentése nincs szinkronban. Meg kell erősítened a helyreállítási kulcsát, hogy továbbra is hozzáférj a csevegés biztonsági mentéséhez."
+ "A csevegés biztonsági mentése nincs szinkronban. Meg kell erősítenie a helyreállítási kulcsát, hogy továbbra is hozzáférjen a csevegés biztonsági mentéséhez."
"Helyreállítási kulcs megerősítése"
"Ez egy egyszeri folyamat, köszönjük a türelmét."
"A fiók beállítása."
@@ -24,6 +24,7 @@ Nincs olvasatlan üzenete!"
"Összes csevegés"
"Megjelölés olvasottként"
"Megjelölés olvasatlanként"
+ "Összes szoba böngészése"
"Úgy tűnik, hogy új eszközt használ. Ellenőrizze egy másik eszközzel, hogy a továbbiakban elérje a titkosított üzeneteket."
"Ellenőrizze, hogy Ön az"
diff --git a/features/roomlist/impl/src/main/res/values-in/translations.xml b/features/roomlist/impl/src/main/res/values-in/translations.xml
index e84fd81fa7..74c90c1319 100644
--- a/features/roomlist/impl/src/main/res/values-in/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-in/translations.xml
@@ -24,6 +24,7 @@ Anda tidak memiliki pesan yang belum dibaca!"
"Semua Obrolan"
"Tandai sebagai dibaca"
"Tandai sebagai belum dibaca"
+ "Cari semua ruangan"
"Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya."
"Verifikasi bahwa ini Anda"
diff --git a/features/roomlist/impl/src/main/res/values-uk/translations.xml b/features/roomlist/impl/src/main/res/values-uk/translations.xml
index 3aaf84f9a5..d7ebacd5f7 100644
--- a/features/roomlist/impl/src/main/res/values-uk/translations.xml
+++ b/features/roomlist/impl/src/main/res/values-uk/translations.xml
@@ -15,7 +15,7 @@
"Ви можете зняти фільтри, щоб побачити інші ваші чати"
"Ви не маєте чатів для цієї категорії"
"Люди"
- "У вас ще немає жодного особистого чату"
+ "Ви ще не маєте жодного особистого чату"
"Кімнати"
"Ви ще не учасник жодної кімнати"
"Непрочитані"
diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml
index 11466fdc56..deeead48c2 100644
--- a/features/roomlist/impl/src/main/res/values/localazy.xml
+++ b/features/roomlist/impl/src/main/res/values/localazy.xml
@@ -11,6 +11,8 @@
"You can add a chat to your favourites in the chat settings.
For now, you can deselect filters in order to see your other chats"
"You don’t have favourite chats yet"
+ "Invites"
+ "You don\'t have any pending invites."
"Low Priority"
"You can deselect filters in order to see your other chats"
"You don’t have chats for this selection"
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
index 6b5877a010..0f2c288a63 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
@@ -239,52 +239,28 @@ class RoomListPresenterTests {
}
}
- @Test
- fun `present - handle RecoveryKeyConfirmation last session`() = runTest {
- val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
- val roomListService = FakeRoomListService().apply {
- postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- }
- val presenter = createRoomListPresenter(
- coroutineScope = scope,
- client = FakeMatrixClient(
- encryptionService = FakeEncryptionService().apply {
- emitIsLastDevice(true)
- },
- roomListService = roomListService
- ),
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val eventSink = consumeItemsUntilPredicate {
- it.contentState is RoomListContentState.Rooms
- }.last().eventSink
- // For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation
- assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
- eventSink(RoomListEvents.DismissRequestVerificationPrompt)
- assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
- scope.cancel()
- }
- }
-
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
+ val encryptionService = FakeEncryptionService().apply {
+ emitRecoveryState(RecoveryState.INCOMPLETE)
+ }
+ val syncService = FakeSyncService(initialState = SyncState.Running)
val presenter = createRoomListPresenter(
- client = FakeMatrixClient(roomListService = roomListService),
+ client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- val eventSink = consumeItemsUntilPredicate {
+ val eventWithContentAsRooms = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
- }.last().eventSink
- assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
+ }.last()
+ val eventSink = eventWithContentAsRooms.eventSink
+ assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
@@ -342,10 +318,10 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- consumeItemsUntilPredicate {
+ val firstItem = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
- }
- assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
+ }.last()
+ assertThat(firstItem.contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
inviteStateFlow.value = InvitesState.SeenInvites
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
index f545b27860..2dbec36395 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt
@@ -43,35 +43,6 @@ import org.junit.runner.RunWith
class RoomListViewTest {
@get:Rule val rule = createAndroidComposeRule()
- @Test
- fun `clicking on close verification banner emits the expected Event`() {
- val eventsRecorder = EventsRecorder()
- rule.setRoomListView(
- state = aRoomListState(
- contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
- eventSink = eventsRecorder,
- )
- )
- val close = rule.activity.getString(CommonStrings.action_close)
- rule.onNodeWithContentDescription(close).performClick()
- eventsRecorder.assertSingle(RoomListEvents.DismissRequestVerificationPrompt)
- }
-
- @Test
- fun `clicking on continue verification banner invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
- ensureCalledOnce { callback ->
- rule.setRoomListView(
- state = aRoomListState(
- contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
- eventSink = eventsRecorder,
- ),
- onVerifyClicked = callback,
- )
- rule.clickOn(CommonStrings.action_continue)
- }
- }
-
@Test
fun `clicking on close recovery key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder()
@@ -185,24 +156,24 @@ private fun AndroidComposeTestRule.setRoomL
state: RoomListState,
onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onSettingsClicked: () -> Unit = EnsureNeverCalled(),
- onVerifyClicked: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
onCreateRoomClicked: () -> Unit = EnsureNeverCalled(),
onInvitesClicked: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
+ onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomListView(
state = state,
onRoomClicked = onRoomClicked,
onSettingsClicked = onSettingsClicked,
- onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
onRoomSettingsClicked = onRoomSettingsClicked,
onMenuActionClicked = onMenuActionClicked,
+ onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
)
}
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt
index d3fc434f25..b3463c549f 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt
@@ -23,6 +23,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@@ -128,10 +131,26 @@ class RoomListSearchPresenterTests {
}
}
}
+
+ @Test
+ fun `present - room directory search`() = runTest {
+ val featureFlagService = FakeFeatureFlagService()
+ featureFlagService.setFeatureEnabled(FeatureFlags.RoomDirectorySearch, true)
+ val presenter = createRoomListSearchPresenter(featureFlagService = featureFlagService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ awaitItem().let { state ->
+ assertThat(state.isRoomDirectorySearchEnabled).isTrue()
+ }
+ }
+ }
}
fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(),
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): RoomListSearchPresenter {
return RoomListSearchPresenter(
dataSource = RoomListSearchDataSource(
@@ -141,6 +160,7 @@ fun TestScope.createRoomListSearchPresenter(
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),
- )
+ ),
+ featureFlagService = featureFlagService,
)
}
diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
index 1fee6418a9..1904ceb2ff 100644
--- a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
+++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt
@@ -19,6 +19,7 @@ package io.element.android.features.securebackup.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import kotlinx.parcelize.Parcelize
@@ -30,14 +31,23 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
@Parcelize
data object EnterRecoveryKey : InitialTarget
+
+ @Parcelize
+ data object CreateNewRecoveryKey : InitialTarget
}
data class Params(val initialElement: InitialTarget) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+ interface Callback : Plugin {
+ fun onCreateNewRecoveryKey()
+ fun onDone()
+ }
+
interface NodeBuilder {
fun params(params: Params): NodeBuilder
+ fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt
index e3d5fde961..d238560463 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt
@@ -36,6 +36,11 @@ class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoi
return this
}
+ override fun callback(callback: SecureBackupEntryPoint.Callback): SecureBackupEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
override fun build(): Node {
return parentNode.createNode(buildContext, plugins)
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
index 172c6672e5..1f22e57c03 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt
@@ -22,12 +22,15 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
+import io.element.android.features.securebackup.impl.createkey.CreateNewRecoveryKeyNode
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
@@ -48,6 +51,7 @@ class SecureBackupFlowNode @AssistedInject constructor(
initialElement = when (plugins.filterIsInstance(SecureBackupEntryPoint.Params::class.java).first().initialElement) {
SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root
SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey
+ SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey -> NavTarget.CreateNewRecoveryKey
},
savedStateMap = buildContext.savedStateMap,
),
@@ -72,8 +76,13 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Parcelize
data object EnterRecoveryKey : NavTarget
+
+ @Parcelize
+ data object CreateNewRecoveryKey : NavTarget
}
+ private val callback = plugins().firstOrNull()
+
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
@@ -119,7 +128,19 @@ class SecureBackupFlowNode @AssistedInject constructor(
createNode(buildContext)
}
NavTarget.EnterRecoveryKey -> {
- createNode(buildContext)
+ val callback = object : SecureBackupEnterRecoveryKeyNode.Callback {
+ override fun onEnterRecoveryKeySuccess() {
+ if (callback != null) {
+ callback.onDone()
+ } else {
+ backstack.pop()
+ }
+ }
+ }
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ NavTarget.CreateNewRecoveryKey -> {
+ createNode(buildContext)
}
}
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt
new file mode 100644
index 0000000000..df1e2d9528
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.createkey
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class CreateNewRecoveryKeyNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext, plugins = plugins) {
+ @Composable
+ override fun View(modifier: Modifier) {
+ CreateNewRecoveryKeyView(
+ modifier = modifier,
+ onBackClicked = ::navigateUp,
+ )
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt
new file mode 100644
index 0000000000..ed3a5cd339
--- /dev/null
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.securebackup.impl.createkey
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.securebackup.impl.R
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.PageTitle
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.modifiers.squareSize
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CreateNewRecoveryKeyView(
+ onBackClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(title = {}, navigationIcon = { BackButton(onClick = onBackClicked) })
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier.padding(padding)
+ ) {
+ PageTitle(
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 40.dp),
+ title = stringResource(R.string.screen_create_new_recovery_key_title),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer())
+ )
+ Content()
+ }
+ }
+}
+
+@Composable
+private fun Content() {
+ Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
+ Item(index = 1, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_1)))
+ Item(index = 2, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2)))
+ Item(
+ index = 3,
+ text = buildAnnotatedString {
+ val resetAllAction = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all)
+ val text = stringResource(R.string.screen_create_new_recovery_key_list_item_3, resetAllAction)
+ append(text)
+ val start = text.indexOf(resetAllAction)
+ val end = start + resetAllAction.length
+ if (start in text.indices && end in text.indices) {
+ addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
+ }
+ }
+ )
+ Item(index = 4, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_4)))
+ Item(index = 5, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_5)))
+ }
+}
+
+@Composable
+private fun Item(index: Int, text: AnnotatedString) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ItemNumber(index = index)
+ Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary)
+ }
+}
+
+@Composable
+private fun ItemNumber(
+ index: Int,
+) {
+ val color = ElementTheme.colors.textPlaceholder
+ Box(
+ modifier = Modifier
+ .border(1.dp, color, CircleShape)
+ .squareSize()
+ ) {
+ Text(
+ modifier = Modifier.padding(1.5.dp),
+ text = index.toString(),
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = color,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun CreateNewRecoveryKeyViewPreview() {
+ ElementPreview {
+ CreateNewRecoveryKeyView(
+ onBackClicked = {},
+ )
+ }
+}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt
index 8c6ae282fa..597ab4c14e 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt
@@ -17,48 +17,36 @@
package io.element.android.features.securebackup.impl.enter
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
-import io.element.android.features.securebackup.impl.R
-import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
-import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.SessionScope
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
@ContributesNode(SessionScope::class)
class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val presenter: SecureBackupEnterRecoveryKeyPresenter,
- private val snackbarDispatcher: SnackbarDispatcher,
) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onEnterRecoveryKeySuccess()
+ }
+
+ private val callback = plugins().first()
+
@Composable
override fun View(modifier: Modifier) {
- val coroutineScope = rememberCoroutineScope()
val state = presenter.present()
SecureBackupEnterRecoveryKeyView(
state = state,
modifier = modifier,
- onDone = {
- coroutineScope.postSuccessSnackbar()
- navigateUp()
- },
+ onDone = callback::onEnterRecoveryKeySuccess,
onBackClicked = ::navigateUp,
)
}
-
- private fun CoroutineScope.postSuccessSnackbar() = launch {
- snackbarDispatcher.post(
- SnackbarMessage(
- messageResId = R.string.screen_recovery_key_confirm_success
- )
- )
- }
}
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt
index ad36c09726..b74078c7f2 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt
@@ -83,7 +83,7 @@ private fun ColumnScope.Buttons(
state: SecureBackupEnterRecoveryKeyState,
) {
Button(
- text = stringResource(id = CommonStrings.action_confirm),
+ text = stringResource(id = CommonStrings.action_continue),
enabled = state.isSubmitEnabled,
showProgress = state.submitAction.isLoading(),
modifier = Modifier.fillMaxWidth(),
diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt
index 7a4d92fcd3..dc8b788025 100644
--- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt
+++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt
@@ -70,7 +70,10 @@ internal fun RecoveryKeyView(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
- text = stringResource(id = CommonStrings.common_recovery_key),
+ text = when (state.recoveryKeyUserStory) {
+ RecoveryKeyUserStory.Enter -> stringResource(R.string.screen_recovery_key_confirm_key_label)
+ else -> stringResource(id = CommonStrings.common_recovery_key)
+ },
modifier = Modifier.padding(start = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
)
diff --git a/features/securebackup/impl/src/main/res/values-be/translations.xml b/features/securebackup/impl/src/main/res/values-be/translations.xml
index d16399978d..58011bad6e 100644
--- a/features/securebackup/impl/src/main/res/values-be/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-be/translations.xml
@@ -5,29 +5,38 @@
"Рэзервовае капіраванне гарантуе, што вы не страціце сваю гісторыю паведамленняў. %1$s."
"Рэзервовая копія"
"Змяніць ключ аднаўлення"
- "Пацвердзіце ключ аднаўлення"
+ "Увядзіце ключ аднаўлення"
"Ваша рэзервовая копія чата зараз не сінхранізавана."
"Наладзьце аднаўленне"
"Атрымайце доступ да зашыфраваных паведамленняў, калі вы страціце ўсе свае прылады або выйдзеце з сістэмы %1$s усюды."
+ "Адкрыйце Element на настольнай прыладзе"
+ "Увайдзіце ў свой уліковы запіс яшчэ раз"
+ "Калі будзе прапанавана пацвердзіць вашу прыладу, выберыце %1$s"
+ "“Скінуць усе”"
+ "Выконвайце інструкцыі, каб стварыць новы ключ аднаўлення"
+ "Захавайце новы ключ аднаўлення ў ме́неджэры пароляў або ў зашыфраванай нататке"
+ "Скіньце шыфраванне для вашага ўліковага запісу з дапамогай іншай прылады"
"Адключыць"
"Вы страціце зашыфраваныя паведамленні, калі выйдзеце з усіх прылад."
- "Вы ўпэўнены, што жадаеце адключыць рэзервовае капіраванне?"
+ "Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"
"Адключэнне рэзервовага капіравання прывядзе да выдалення бягучай рэзервовай копіі ключа шыфравання і адключэння іншых функцый бяспекі. У гэтым выпадку вы:"
"Няма зашыфраванай гісторыі паведамленняў на новых прыладах"
"Калі вы выходзіце з сістэмы, то губляеце доступ да зашыфраваных паведамленняў %1$s усюды"
- "Вы ўпэўнены, што жадаеце адключыць рэзервовае капіраванне?"
+ "Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"
"Атрымайце новы ключ аднаўлення, калі вы страцілі існуючы. Пасля змены ключа аднаўлення ваш стары больш не будзе працаваць."
"Стварыць новы ключ аднаўлення"
"Пераканайцеся, што вы можаце захаваць ключ аднаўлення ў бяспечным месцы"
"Ключ аднаўлення зменены"
"Змяніць ключ аднаўлення?"
- "Увядзіце ключ аднаўлення, каб пацвердзіць доступ да рэзервовай копіі чата."
+ "Стварыць новы ключ аднаўлення"
+ "Пераканайцеся, што ніхто не бачыць гэты экран!"
"Паўтарыце спробу, каб пацвердзіць доступ да рэзервовай копіі чата."
"Няправільны ключ аднаўлення"
- "Увядзіце код з 48 сімвалаў."
+ "Калі ў вас ёсць ключ аднаўлення або парольная фраза, гэта таксама будзе працаваць."
+ "Ключ аднаўлення або код доступу"
"Увесці…"
"Ключ аднаўлення пацверджаны"
- "Пацвердзіце ключ аднаўлення"
+ "Увядзіце ключ аднаўлення або код доступу"
"Ключ аднаўлення скапіраваны"
"Стварэнне…"
"Захаваць ключ аднаўлення"
diff --git a/features/securebackup/impl/src/main/res/values-cs/translations.xml b/features/securebackup/impl/src/main/res/values-cs/translations.xml
index a34d77aeee..f8a2fe8747 100644
--- a/features/securebackup/impl/src/main/res/values-cs/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml
@@ -9,6 +9,13 @@
"Vaše záloha chatu není aktuálně synchronizována."
"Nastavení obnovy"
"Získejte přístup ke svým zašifrovaným zprávám, pokud ztratíte všechna zařízení nebo jste všude odhlášeni z %1$s."
+ "Otevřít Element na stolním počítači"
+ "Znovu se přihlaste ke svému účtu"
+ "Když budete vyzváni k ověření vašeho zařízení, vyberte %1$s"
+ "\"Resetovat vše\""
+ "Postupujte podle pokynů k vytvoření nového obnovovacího klíče"
+ "Uložte nový klíč pro obnovení do správce hesel nebo do zašifrované poznámky"
+ "Obnovte šifrování účtu pomocí jiného zařízení"
"Vypnout"
"Pokud se odhlásíte ze všech zařízení, přijdete o zašifrované zprávy."
"Opravdu chcete vypnout zálohování?"
@@ -21,10 +28,12 @@
"Ujistěte se, že můžete klíč pro obnovení uložit někde v bezpečí"
"Klíč pro obnovení byl změněn"
"Změnit klíč pro obnovení?"
- "Zadejte klíč pro obnovení a potvrďte přístup k záloze chatu."
+ "Vytvořit nový klíč pro obnovení"
+ "Ujistěte se, že tuto obrazovku nikdo nevidí!"
"Zkuste prosím znovu potvrdit přístup k záloze chatu."
"Nesprávný klíč pro obnovení"
- "Zadejte kód o délce 48 znaků."
+ "Pokud máte bezpečnostní klíč nebo bezpečnostní frázi, bude to fungovat také."
+ "Klíč pro obnovení nebo přístupový kód"
"Zadejte…"
"Klíč pro obnovení potvrzen"
"Potvrďte klíč pro obnovení"
diff --git a/features/securebackup/impl/src/main/res/values-de/translations.xml b/features/securebackup/impl/src/main/res/values-de/translations.xml
index 5ec8f99e8c..7336b28b70 100644
--- a/features/securebackup/impl/src/main/res/values-de/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-de/translations.xml
@@ -9,6 +9,26 @@
"Dein Chat-Backup ist derzeit nicht synchronisiert."
"Wiederherstellung einrichten"
"Erhalte Zugriff auf deine verschlüsselten Nachrichten, wenn du alle deine Geräte verlierst oder von %1$s überall abgemeldet bist."
+
+ "Öffne "
+ "Element"
+ " auf einem "
+ "Desktop-Gerät"
+
+ "Melde dich erneut bei deinem Konto an"
+ "Wenn du aufgefordert wirst dein Gerät zu verifizieren, wähle \"%1$s\"."
+ "Alles zurücksetzen"
+ "Folge den Anweisungen, um einen neuen Wiederherstellungsschlüssel zu erstellen"
+
+ "Speichere deinen neuen "
+ "Wiederherstellungsschlüssel"
+ " in einem Passwortmanager oder einer verschlüsselten Notiz"
+
+
+ "Erstelle einen neuen "
+ "Wiederherstellungsschlüssel"
+ " mit einem anderen Gerät"
+
"Ausschalten"
"Du verlierst deine verschlüsselten Nachrichten, wenn du auf allen Geräten abgemeldet bist."
"Bist du sicher, dass du das Backup ausschalten willst?"
@@ -21,13 +41,22 @@
"Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"
"Wiederherstellungsschlüssel geändert"
"Wiederherstellungsschlüssel ändern?"
- "Gib deinen Wiederherstellungsschlüssel ein, um den Zugriff auf dein Chat-Backup zu bestätigen."
+
+ "Neuen "
+ "Wiederherstellungsschlüssel"
+ " erstellen"
+
+ "Sorge dafür, dass niemand diesen Bildschirm sehen kann!"
"Bitte versuche es noch einmal, um den Zugriff auf dein Chat-Backup zu bestätigen."
"Falscher Wiederherstellungsschlüssel"
- "Gib den 48-stelligen Code ein."
+ "Dies funktioniert auch mit einem Sicherheitsschlüssel oder Sicherheitsphrase."
+
+ "Wiederherstellungsschlüssel"
+ " oder Passcode"
+
"Eingeben…"
"Wiederherstellungsschlüssel bestätigt"
- "Wiederherstellungsschlüssel bestätigen."
+ "Wiederherstellungsschlüssel oder Passcode bestätigen"
"Wiederherstellungsschlüssel kopiert"
"Generieren…"
"Wiederherstellungsschlüssel speichern"
diff --git a/features/securebackup/impl/src/main/res/values-hu/translations.xml b/features/securebackup/impl/src/main/res/values-hu/translations.xml
index 0eff260322..1bd37c4b64 100644
--- a/features/securebackup/impl/src/main/res/values-hu/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-hu/translations.xml
@@ -9,6 +9,13 @@
"A csevegéselőzményei nincsenek szinkronban."
"Helyreállítás beállítása"
"Szerezzen hozzáférést a titkosított üzeneteihez, ha elvesztette az összes eszközét, vagy ha mindenütt kijelentkezett az %1$sből."
+ "Nyissa meg az Elementet egy asztali eszközön"
+ "Jelentkezzen be újra a fiókjába"
+ "Amikor az eszköz ellenőrzését kéri, válassza ezt a lehetőséget: %1$s"
+ "„Minden visszaállítása”"
+ "Kövesse az utasításokat egy új helyreállítási kulcs létrehozásához"
+ "Mentse az új helyreállítási kulcsot egy jelszókezelőbe vagy egy titkosított jegyzetbe."
+ "A fiók titkosításának visszaállítása egy másik eszköz használatával"
"Kikapcsolás"
"Ha kijelentkezik az összes eszközéről, akkor elveszti a titkosított üzeneteit."
"Biztos, hogy kikapcsolja a biztonsági mentéseket?"
@@ -21,13 +28,15 @@
"Gondoskodjon arról, hogy biztonságos helyen tárolja a helyreállítási kulcsát"
"Helyreállítási kulcs lecserélve"
"Módosítja a helyreállítási kulcsot?"
- "Adja meg a helyreállítási kulcsát, hogy megerősítse a csevegések biztonsági mentéséhez való hozzáférését."
+ "Új helyreállítási kulcs létrehozása"
+ "Győződjön meg arról, hogy senki sem látja ezt a képernyőt!"
"Próbálja meg újra megerősíteni a csevegés biztonsági mentéséhez való hozzáférését."
"Helytelen helyreállítási kulcs"
- "Adja meg a 48 karakteres kódot."
+ "Ha van helyreállítási kulcsa vagy titkos jelmondata/kulcsa, akkor ez is fog működni."
+ "Helyreállítási kulcs vagy jelkód"
"Megadás…"
"Helyreállítási kulcs megerősítve"
- "Erősítse meg a helyreállítási kulcsát"
+ "Adja meg a helyreállítási kulcsát vagy a jelkódját"
"Helyreállítási kulcs másolva"
"Előállítás…"
"Helyreállítási kulcs mentése"
diff --git a/features/securebackup/impl/src/main/res/values-in/translations.xml b/features/securebackup/impl/src/main/res/values-in/translations.xml
index c7712832d2..016d0e3057 100644
--- a/features/securebackup/impl/src/main/res/values-in/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-in/translations.xml
@@ -9,6 +9,13 @@
"Pencadangan percakapan Anda saat ini tidak tersinkron."
"Siapkan pemulihan"
"Dapatkan akses ke pesan terenkripsi Anda jika Anda kehilangan semua perangkat Anda atau keluar dari %1$s di mana pun."
+ "Buka Element di perangkat desktop"
+ "Masuk ke akun Anda lagi"
+ "Saat diminta untuk memverifikasi perangkat Anda, pilih %1$s"
+ "“Atur ulang semua”"
+ "Ikuti petunjuk untuk membuat kunci pemulihan baru"
+ "Simpan kunci pemulihan baru Anda dalam pengelola kata sandi atau catatan terenkripsi"
+ "Atur ulang enkripsi untuk akun Anda menggunakan perangkat lain"
"Matikan"
"Anda akan kehilangan pesan terenkripsi jika Anda keluar dari semua perangkat."
"Apakah Anda yakin ingin mematikan pencadangan?"
@@ -21,13 +28,15 @@
"Pastikan Anda dapat menyimpan kunci pemulihan Anda di tempat yang aman"
"Kunci pemulihan diganti"
"Ubah kunci pemulihan?"
- "Masukkan kunci pemulihan Anda untuk mengonfirmasi akses ke cadangan percakapan Anda."
+ "Buat kunci pemulihan baru"
+ "Pastikan tidak ada yang bisa melihat layar ini!"
"Silakan coba lagi untuk mengonfirmasi akses ke cadangan percakapan Anda."
"Kunci pemulihan salah"
- "Masukkan kode 48 karakter."
+ "Jika Anda memiliki frasa sandi pemulihan atau frasa/kunci sandi rahasia, ini juga dapat digunakan."
+ "Kunci pemulihan atau kode sandi"
"Masukkan…"
"Kunci pemulihan dikonfirmasi"
- "Konfirmasi kunci pemulihan Anda"
+ "Konfirmasi kunci pemulihan atau kode sandi Anda"
"Kunci pemulihan disalin"
"Membuat…"
"Simpan kunci pemulihan"
diff --git a/features/securebackup/impl/src/main/res/values-ru/translations.xml b/features/securebackup/impl/src/main/res/values-ru/translations.xml
index fd2bb28208..acacc8d60b 100644
--- a/features/securebackup/impl/src/main/res/values-ru/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-ru/translations.xml
@@ -4,11 +4,28 @@
"Включить резервное копирование"
"Резервное копирование гарантирует, что вы не потеряете историю сообщений. %1$s."
"Резервное копирование"
- "Изменить ключ восстановления"
- "Подтвердить ключ восстановления"
+
+ "Изменить "
+ "ключ восстановления"
+
+
+ "Ввести "
+ "ключ восстановления"
+
"Резервная копия чата в настоящее время не синхронизирована."
"Настроить восстановление"
"Получите доступ к зашифрованным сообщениям, если вы потеряете все свои устройства или выйдете из системы %1$s отовсюду."
+ "Откройте Element на настольном устройстве"
+ "Войдите в свой аккаунт еще раз"
+ "Когда вас попросят подтвердить устройство, выберите %1$s"
+ "“Сбросить все”"
+ "Следуйте инструкциям, чтобы создать новый ключ восстановления"
+
+ "Сохраните новый "
+ "ключ восстановления"
+ " в менеджере паролей или зашифрованной заметке"
+
+ "Сбросьте шифрование вашей учетной записи с помощью другого устройства."
"Выключить"
"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."
"Вы действительно хотите отключить резервное копирование?"
@@ -17,27 +34,62 @@
"Потерять доступ к зашифрованным сообщениям, если вы вышли из %1$s любой точки мира"
"Вы действительно хотите отключить резервное копирование?"
"Получите новый ключ восстановления, если вы потеряли существующий. После смены ключа восстановления старый ключ больше не будет работать."
- "Создать новый ключ восстановления"
+
+ "Создать новый "
+ "ключ восстановления"
+
"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"
- "Ключ восстановления изменен"
+
+ "Ключ восстановления"
+ " изменен"
+
"Изменить ключ восстановления?"
- "Введите ключ восстановления, чтобы подтвердить доступ к резервной копии чата."
+
+ "Создать новый "
+ "ключ восстановления"
+
+ "Убедитесь, что никто не видит этот экран!"
"Пожалуйста, попробуйте еще раз, чтобы подтвердить доступ к резервной копии чата."
- "Неверный ключ восстановления"
- "Введите 48 значный код."
+
+ "Неверный "
+ "ключ восстановления"
+
+ "Если у вас есть пароль для восстановления или секретный пароль/ключ, это тоже сработает."
+
+ "Ключ восстановления"
+ " или пароль"
+
"Вход…"
- "Ключ восстановления подтвержден"
- "Подтвердите ключ восстановления"
- "Ключ восстановления скопирован"
+
+ "Ключ восстановления"
+ " подтвержден"
+
+
+ "Подтвердите "
+ "ключ восстановления"
+
+
+ "Ключ восстановления"
+ " скопирован"
+
"Генерация…"
- "Сохранить ключ восстановления"
+
+ "Сохранить "
+ "ключ восстановления"
+
"Запишите ключ восстановления в безопасном месте или сохраните его в менеджере паролей."
"Нажмите, чтобы скопировать ключ восстановления"
- "Сохраните ключ восстановления"
+
+ "Сохраните "
+ "ключ восстановления"
+
"После этого шага вы не сможете получить доступ к новому ключу восстановления."
"Вы сохранили ключ восстановления?"
"Резервная копия чата защищена ключом восстановления. Если после настройки вам понадобится новый ключ восстановления, вы можете создать его заново, выбрав «Изменить ключ восстановления»."
- "Сгенерируйте свой ключ восстановления"
+
+ "Создайте "
+ "ключ восстановления"
+
"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"
"Настройка восстановления выполнена успешно"
"Настроить восстановление"
diff --git a/features/securebackup/impl/src/main/res/values-sk/translations.xml b/features/securebackup/impl/src/main/res/values-sk/translations.xml
index ffc7e78c87..fdc70027b3 100644
--- a/features/securebackup/impl/src/main/res/values-sk/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sk/translations.xml
@@ -9,6 +9,13 @@
"Vaša záloha konverzácie nie je momentálne synchronizovaná."
"Nastaviť obnovovanie"
"Získajte prístup k vašim šifrovaným správam aj keď stratíte všetky svoje zariadenia alebo sa odhlásite zo všetkých %1$s zariadení."
+ "Otvoriť Element v stolnom počítači"
+ "Znova sa prihláste do svojho účtu"
+ "Keď sa zobrazí výzva na overenie vášho zariadenia, vyberte %1$s"
+ "\"Obnoviť všetko\""
+ "Postupujte podľa pokynov na vytvorenie nového kľúča na obnovenie"
+ "Uložte si nový kľúč na obnovenie do správcu hesiel alebo do zašifrovanej poznámky"
+ "Obnovte šifrovanie vášho účtu pomocou iného zariadenia"
"Vypnúť"
"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení"
"Ste si istí, že chcete vypnúť zálohovanie?"
@@ -21,13 +28,15 @@
"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"
"Kľúč na obnovenie bol zmenený"
"Zmeniť kľúč na obnovenie?"
- "Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie."
+ "Vytvoriť nový kľúč na obnovenie"
+ "Uistite sa, že túto obrazovku nikto nevidí!"
"Skúste prosím znova potvrdiť prístup k vašej zálohe konverzácie."
"Nesprávny kľúč na obnovenie"
- "Zadajte 48-znakový kód."
+ "Ak máte frázu na obnovenie alebo tajné heslo/kľúč, bude to tiež fungovať."
+ "Kľúč na obnovenie alebo prístupový kód"
"Zadať…"
"Kľúč na obnovu potvrdený"
- "Potvrďte kľúč na obnovenie"
+ "Zadajte kľúč na obnovenie alebo prístupový kód"
"Skopírovaný kľúč na obnovenie"
"Generovanie…"
"Uložiť kľúč na obnovenie"
diff --git a/features/securebackup/impl/src/main/res/values-sv/translations.xml b/features/securebackup/impl/src/main/res/values-sv/translations.xml
index db8306b302..99c9c97b8a 100644
--- a/features/securebackup/impl/src/main/res/values-sv/translations.xml
+++ b/features/securebackup/impl/src/main/res/values-sv/translations.xml
@@ -4,5 +4,37 @@
"Slå på säkerhetskopiering"
"Säkerhetskopior ser till att du inte blir av med din meddelandehistorik. %1$s."
"Säkerhetskopia"
+ "Byt återställningsnyckel"
+ "Ange återställningsnyckel"
+ "Din chattsäkerhetskopia är för närvarande osynkroniserad."
"Ställ in återställning"
+ "Få tillgång till dina krypterade meddelanden om du tappar bort alla dina enheter eller blir utloggad ur %1$s överallt."
+ "Stäng av"
+ "Du kommer att förlora dina krypterade meddelanden om du loggas ut från alla enheter."
+ "Är du säker på att du vill stänga av säkerhetskopiering?"
+ "Om du stänger av säkerhetskopiering tas din nuvarande säkerhetskopiering av krypteringsnycklar bort och andra säkerhetsfunktioner stängs av. I det här fallet kommer du att:"
+ "Inte ha krypterad meddelandehistorik på nya enheter"
+ "Förlora åtkomsten till dina krypterade meddelanden om du loggas ut ur %1$s överallt"
+ "Är du säker på att du vill stänga av säkerhetskopiering?"
+ "Få en ny återställningsnyckel om du har tappat bort din befintliga. När du har bytt din återställningsnyckel fungerar din gamla inte längre."
+ "Generera en ny återställningsnyckel"
+ "Se till att du kan lagra din återställningsnyckel någonstans säkert"
+ "Återställningsnyckel ändrad"
+ "Byt återställningsnyckel?"
+ "Ange din återställningsnyckel för att bekräfta åtkomst till din chattsäkerhetskopia."
+ "Ange koden på 48 tecken."
+ "Ange …"
+ "Återställningsnyckel bekräftad"
+ "Ange din återställningsnyckel"
+ "Spara återställningsnyckeln"
+ "Skriv ner din återställningsnyckel någonstans säkert eller spara den i en lösenordshanterare."
+ "Tryck för att kopiera återställningsnyckeln"
+ "Spara din återställningsnyckel"
+ "Du kommer inte att kunna komma åt din nya återställningsnyckel efter det här steget."
+ "Har du sparat din återställningsnyckel?"
+ "Din chattsäkerhetskopia skyddas av en återställningsnyckel. Om du behöver en ny återställningsnyckel efter installationen kan du återskapa genom att välja ”Byt återställningsnyckel”."
+ "Generera din återställningsnyckel"
+ "Se till att du kan lagra din återställningsnyckel någonstans säkert"
+ "Konfiguration av återställning lyckades"
+ "Ställ in återställning"
diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml
index f05725a075..9e188d1251 100644
--- a/features/securebackup/impl/src/main/res/values/localazy.xml
+++ b/features/securebackup/impl/src/main/res/values/localazy.xml
@@ -9,6 +9,13 @@
"Your chat backup is currently out of sync."
"Set up recovery"
"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."
+ "Open Element in a desktop device"
+ "Sign into your account again"
+ "When asked to verify your device, select %1$s"
+ "“Reset all”"
+ "Follow the instructions to create a new recovery key"
+ "Save your new recovery key in a password manager or encrypted note"
+ "Reset the encryption for your account using another device"
"Turn off"
"You will lose your encrypted messages if you are signed out of all devices."
"Are you sure you want to turn off backup?"
@@ -21,13 +28,15 @@
"Make sure you can store your recovery key somewhere safe"
"Recovery key changed"
"Change recovery key?"
- "Enter your recovery key to confirm access to your chat backup."
+ "Create new recovery key"
+ "Make sure nobody can see this screen!"
"Please try again to confirm access to your chat backup."
"Incorrect recovery key"
- "Enter the 48 character code."
+ "If you have a security key or security phrase, this will work too."
+ "Recovery key or passcode"
"Enter…"
"Recovery key confirmed"
- "Enter your recovery key"
+ "Enter your recovery key or passcode"
"Copied recovery key"
"Generating…"
"Save recovery key"
diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt
index df3549b0f8..9827d8d720 100644
--- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt
+++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt
@@ -38,6 +38,7 @@ fun aSignedOutState() = SignedOutState(
fun aSessionData(
sessionId: SessionId = SessionId("@alice:server.org"),
isTokenValid: Boolean = false,
+ needsVerification: Boolean = false,
): SessionData {
return SessionData(
userId = sessionId.value,
@@ -51,5 +52,6 @@ fun aSessionData(
isTokenValid = isTokenValid,
loginType = LoginType.UNKNOWN,
passphrase = null,
+ needsVerification = needsVerification,
)
}
diff --git a/features/signedout/impl/src/main/res/values-be/translations.xml b/features/signedout/impl/src/main/res/values-be/translations.xml
index 42cb5e41ff..d95aa0df81 100644
--- a/features/signedout/impl/src/main/res/values-be/translations.xml
+++ b/features/signedout/impl/src/main/res/values-be/translations.xml
@@ -1,6 +1,6 @@
- "Вы змянілі свой пароль на іншым сеансе"
+ "Вы змянілі свой пароль у іншым сеансе"
"Вы выдалілі сеанс з іншага сеансу"
"Адміністратар вашага сервера ануляваў ваш доступ"
"Магчыма, вы выйшлі з сістэмы па адной з прычын, пералічаных ніжэй. Калі ласка, увайдзіце яшчэ раз, каб працягнуць выкарыстанне %s."
diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
index 8d19ca5698..70600f7f7c 100644
--- a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
+++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt
@@ -31,6 +31,7 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onEnterRecoveryKey()
+ fun onCreateNewRecoveryKey()
fun onDone()
}
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
index cc97faa6e3..222683156a 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt
@@ -34,17 +34,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Assisted plugins: List,
private val presenter: VerifySelfSessionPresenter,
) : Node(buildContext, plugins = plugins) {
- private fun onEnterRecoveryKey() {
- plugins().forEach {
- it.onEnterRecoveryKey()
- }
- }
-
- private fun onDone() {
- plugins().forEach {
- it.onDone()
- }
- }
+ private val callback = plugins().first()
@Composable
override fun View(modifier: Modifier) {
@@ -52,8 +42,9 @@ class VerifySelfSessionNode @AssistedInject constructor(
VerifySelfSessionView(
state = state,
modifier = modifier,
- onEnterRecoveryKey = ::onEnterRecoveryKey,
- goBack = ::onDone,
+ onEnterRecoveryKey = callback::onEnterRecoveryKey,
+ onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey,
+ onFinished = callback::onDone,
)
}
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
index 2a7740d8d0..c09b48946d 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt
@@ -23,10 +23,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import com.freeletics.flowredux.compose.rememberStateAndDispatch
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.meta.BuildMeta
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.verification.SessionVerificationService
@@ -35,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import javax.inject.Inject
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
@@ -43,20 +48,28 @@ class VerifySelfSessionPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val stateMachine: VerifySelfSessionStateMachine,
+ private val buildMeta: BuildMeta,
) : Presenter {
@Composable
override fun present(): VerifySelfSessionState {
+ val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset()
}
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
+ var skipVerification by remember { mutableStateOf(false) }
+ val needsVerification by sessionVerificationService.needsVerificationFlow.collectAsState()
val verificationFlowStep by remember {
derivedStateOf {
- stateAndDispatch.state.value.toVerificationStep(
- canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
- )
+ when {
+ skipVerification -> VerifySelfSessionState.VerificationStep.Skipped
+ needsVerification -> stateAndDispatch.state.value.toVerificationStep(
+ canEnterRecoveryKey = recoveryState == RecoveryState.INCOMPLETE
+ )
+ else -> VerifySelfSessionState.VerificationStep.Completed
+ }
}
}
// Start this after observing state machine
@@ -68,14 +81,19 @@ class VerifySelfSessionPresenter @Inject constructor(
when (event) {
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
- VerifySelfSessionViewEvents.Restart -> stateAndDispatch.dispatchAction(StateMachineEvent.Restart)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
- VerifySelfSessionViewEvents.CancelAndClose -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
+ VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
+ VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
+ VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
+ sessionVerificationService.saveVerifiedState(true)
+ skipVerification = true
+ }
}
}
return VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
+ displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents,
)
}
@@ -85,7 +103,7 @@ class VerifySelfSessionPresenter @Inject constructor(
): VerifySelfSessionState.VerificationStep =
when (val machineState = this) {
StateMachineState.Initial, null -> {
- VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey)
+ VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = canEnterRecoveryKey, isLastDevice = encryptionService.isLastDevice.value)
}
StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification,
@@ -118,7 +136,7 @@ class VerifySelfSessionPresenter @Inject constructor(
private fun CoroutineScope.observeVerificationService() {
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
when (verificationAttemptState) {
- VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Restart)
+ VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Reset)
VerificationFlowState.AcceptedVerificationRequest -> {
stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest)
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
index fa3cb68adf..30d91b8fa4 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt
@@ -24,15 +24,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
@Immutable
data class VerifySelfSessionState(
val verificationFlowStep: VerificationStep,
+ val displaySkipButton: Boolean,
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) {
@Stable
sealed interface VerificationStep {
- data class Initial(val canEnterRecoveryKey: Boolean) : VerificationStep
+ data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean) : VerificationStep
data object Canceled : VerificationStep
data object AwaitingOtherDeviceResponse : VerificationStep
data object Ready : VerificationStep
data class Verifying(val data: SessionVerificationData, val state: AsyncData) : VerificationStep
data object Completed : VerificationStep
+ data object Skipped : VerificationStep
}
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt
index 2a1c63370e..29a484521a 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt
@@ -20,24 +20,35 @@
package io.element.android.features.verifysession.impl
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
+import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.core.data.tryOrNull
+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.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.timeout
import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
import com.freeletics.flowredux.dsl.State as MachineState
+@OptIn(FlowPreview::class)
class VerifySelfSessionStateMachine @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
+ private val encryptionService: EncryptionService,
) : FlowReduxStateMachine(
initialState = State.Initial
) {
init {
spec {
inState {
- on { _: Event.RequestVerification, state: MachineState ->
+ on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification }
}
- on { _: Event.StartSasVerification, state: MachineState ->
+ on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification }
}
}
@@ -45,12 +56,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
onEnterEffect {
sessionVerificationService.requestVerification()
}
- on { _: Event.DidAcceptVerificationRequest, state: MachineState ->
+ on { _: Event.DidAcceptVerificationRequest, state ->
state.override { State.VerificationRequestAccepted }
}
- on { _: Event.DidFail, state: MachineState ->
- state.override { State.Initial }
- }
}
inState {
onEnterEffect {
@@ -58,25 +66,28 @@ class VerifySelfSessionStateMachine @Inject constructor(
}
}
inState {
- on { _: Event.StartSasVerification, state: MachineState ->
+ on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification }
}
}
inState {
- on { _: Event.Restart, state: MachineState ->
+ on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification }
}
+ on { _: Event.Reset, state ->
+ state.override { State.Initial }
+ }
}
inState {
- on { event: Event.DidReceiveChallenge, state: MachineState ->
+ on { event: Event.DidReceiveChallenge, state ->
state.override { State.Verifying.ChallengeReceived(event.data) }
}
}
inState {
- on { _: Event.AcceptChallenge, state: MachineState ->
+ on { _: Event.AcceptChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = true) }
}
- on { _: Event.DeclineChallenge, state: MachineState ->
+ on { _: Event.DeclineChallenge, state ->
state.override { State.Verifying.Replying(state.snapshot.data, accept = false) }
}
}
@@ -88,11 +99,21 @@ class VerifySelfSessionStateMachine @Inject constructor(
sessionVerificationService.declineVerification()
}
}
- on { _: Event.DidAcceptChallenge, state: MachineState ->
+ on { _: Event.DidAcceptChallenge, state ->
+ // If a key backup exists, wait until it's restored or a timeout happens
+ val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse()
+ if (hasBackup) {
+ tryOrNull {
+ encryptionService.recoveryStateStateFlow.filter { it == RecoveryState.ENABLED }
+ .timeout(10.seconds)
+ .first()
+ }
+ }
state.override { State.Completed }
}
}
inState {
+ // TODO The 'Canceling' -> 'Canceled' transitions doesn't seem to work anymore, check if something changed in the Rust SDK
onEnterEffect {
sessionVerificationService.cancelVerification()
}
@@ -102,21 +123,24 @@ class VerifySelfSessionStateMachine @Inject constructor(
state.override { State.SasVerificationStarted }
}
on { _: Event.Cancel, state: MachineState ->
- if (state.snapshot in sequenceOf(
- State.Initial,
- State.Completed,
- State.Canceled
- )) {
- state.noChange()
- } else {
- state.override { State.Canceling }
+ when (state.snapshot) {
+ State.Initial, State.Completed, State.Canceled -> state.noChange()
+ // For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
+ // `Canceling` state to `Canceled` automatically anymore
+ else -> {
+ sessionVerificationService.cancelVerification()
+ state.override { State.Canceled }
+ }
}
}
on { _: Event.DidCancel, state: MachineState ->
state.override { State.Canceled }
}
on { _: Event.DidFail, state: MachineState ->
- state.override { State.Canceled }
+ when (state.snapshot) {
+ is State.RequestingVerification -> state.override { State.Initial }
+ else -> state.override { State.Canceled }
+ }
}
}
}
@@ -190,7 +214,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
/** Request failed. */
data object DidFail : Event
- /** Restart the verification flow. */
- data object Restart : Event
+ /** Reset the verification flow to the initial state. */
+ data object Reset : Event
}
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
index 59d42f11cd..c066d48613 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt
@@ -17,6 +17,7 @@
package io.element.android.features.verifysession.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
@@ -24,27 +25,34 @@ import io.element.android.libraries.matrix.api.verification.VerificationEmoji
open class VerifySelfSessionStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
- aVerifySelfSessionState(),
+ aVerifySelfSessionState(displaySkipButton = true),
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
+ verificationFlowStep = VerificationStep.AwaitingOtherDeviceResponse
),
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
+ verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
+ verificationFlowStep = VerificationStep.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
),
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled
+ verificationFlowStep = VerificationStep.Canceled
),
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready
+ verificationFlowStep = VerificationStep.Ready
),
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
+ verificationFlowStep = VerificationStep.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true)
+ verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = false)
+ ),
+ aVerifySelfSessionState(
+ verificationFlowStep = VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = true)
+ ),
+ aVerifySelfSessionState(
+ verificationFlowStep = VerificationStep.Completed,
+ displaySkipButton = true,
),
// Add other state here
)
@@ -63,10 +71,12 @@ private fun aDecimalsSessionVerificationData(
}
internal fun aVerifySelfSessionState(
- verificationFlowStep: VerifySelfSessionState.VerificationStep = VerifySelfSessionState.VerificationStep.Initial(false),
+ verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false, isLastDevice = false),
+ displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
+ displaySkipButton = displaySkipButton,
eventSink = eventSink,
)
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
index 69fdfc5dfc..e54ba31872 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt
@@ -28,11 +28,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -42,56 +43,81 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.emoji.toEmojiResource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
-import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
-import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifySelfSessionView(
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
- goBack: () -> Unit,
+ onCreateNewRecoveryKey: () -> Unit,
+ onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
- fun goBackAndCancelIfNeeded() {
- state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
- goBack()
+ fun resetFlow() {
+ state.eventSink(VerifySelfSessionViewEvents.Reset)
}
- if (state.verificationFlowStep is FlowStep.Completed) {
- goBack()
+ val updatedOnFinished by rememberUpdatedState(newValue = onFinished)
+ LaunchedEffect(state.verificationFlowStep, updatedOnFinished) {
+ if (state.verificationFlowStep is FlowStep.Skipped) {
+ updatedOnFinished()
+ }
}
BackHandler {
- goBackAndCancelIfNeeded()
+ when (state.verificationFlowStep) {
+ is FlowStep.Canceled -> resetFlow()
+ is FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
+ is FlowStep.Verifying -> {
+ if (!state.verificationFlowStep.state.isLoading()) {
+ state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
+ }
+ }
+ else -> Unit
+ }
}
val verificationFlowStep = state.verificationFlowStep
- val buttonsVisible by remember(verificationFlowStep) {
- derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed }
- }
HeaderFooterPage(
modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ actions = {
+ if (state.displaySkipButton && state.verificationFlowStep != FlowStep.Completed) {
+ TextButton(
+ text = stringResource(CommonStrings.action_skip),
+ onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
+ )
+ }
+ }
+ )
+ },
header = {
HeaderContent(verificationFlowStep = verificationFlowStep)
},
footer = {
- if (buttonsVisible) {
- BottomMenu(
- screenState = state,
- goBack = ::goBackAndCancelIfNeeded,
- onEnterRecoveryKey = onEnterRecoveryKey
- )
- }
+ BottomMenu(
+ screenState = state,
+ goBack = ::resetFlow,
+ onEnterRecoveryKey = onEnterRecoveryKey,
+ onCreateNewRecoveryKey = onCreateNewRecoveryKey,
+ onFinished = onFinished,
+ )
}
) {
Content(flowState = verificationFlowStep)
@@ -100,61 +126,52 @@ fun VerifySelfSessionView(
@Composable
private fun HeaderContent(verificationFlowStep: FlowStep) {
- val iconResourceId = when (verificationFlowStep) {
- is FlowStep.Initial -> R.drawable.ic_verification_devices
- FlowStep.Canceled -> R.drawable.ic_verification_warning
- FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting
- FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji
+ val iconStyle = when (verificationFlowStep) {
+ is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid())
+ FlowStep.Canceled -> BigIcon.Style.AlertSolid
+ FlowStep.Ready, is FlowStep.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
+ FlowStep.Completed -> BigIcon.Style.SuccessSolid
+ is FlowStep.Skipped -> return
}
val titleTextId = when (verificationFlowStep) {
- is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_title
+ is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
FlowStep.Canceled -> CommonStrings.common_verification_cancelled
- FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_title
- FlowStep.Ready,
- FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_title
+ FlowStep.Ready -> R.string.screen_session_verification_compare_emojis_title
+ FlowStep.Completed -> R.string.screen_identity_confirmed_title
is FlowStep.Verifying -> when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title
}
+ is FlowStep.Skipped -> return
}
val subtitleTextId = when (verificationFlowStep) {
- is FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_subtitle
+ is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
- FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_subtitle
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
- FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_subtitle
+ FlowStep.Completed -> R.string.screen_identity_confirmed_subtitle
is FlowStep.Verifying -> when (verificationFlowStep.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle
is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_subtitle
}
+ is FlowStep.Skipped -> return
}
- IconTitleSubtitleMolecule(
- modifier = Modifier.padding(top = 60.dp),
- iconResourceId = iconResourceId,
+ PageTitle(
+ iconStyle = iconStyle,
title = stringResource(id = titleTextId),
- subTitle = stringResource(id = subtitleTextId)
+ subtitle = stringResource(id = subtitleTextId)
)
}
@Composable
private fun Content(flowState: FlowStep) {
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
- when (flowState) {
- is FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
- FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
- is FlowStep.Verifying -> ContentVerifying(flowState)
+ if (flowState is FlowStep.Verifying) {
+ ContentVerifying(flowState)
}
}
}
-@Composable
-private fun ContentWaiting() {
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
- CircularProgressIndicator()
- }
-}
-
@Composable
private fun ContentVerifying(verificationFlowStep: FlowStep.Verifying) {
when (verificationFlowStep.data) {
@@ -211,77 +228,114 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
private fun BottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
+ onCreateNewRecoveryKey: () -> Unit,
goBack: () -> Unit,
+ onFinished: () -> Unit,
) {
val verificationViewState = screenState.verificationFlowStep
val eventSink = screenState.eventSink
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is AsyncData.Loading
- val positiveButtonTitle = when (verificationViewState) {
- is FlowStep.Initial -> R.string.screen_session_verification_positive_button_initial
- FlowStep.Canceled -> R.string.screen_session_verification_positive_button_canceled
- is FlowStep.Verifying -> {
- if (isVerifying) {
- R.string.screen_session_verification_positive_button_verifying_ongoing
+
+ when (verificationViewState) {
+ is FlowStep.Initial -> {
+ if (verificationViewState.isLastDevice) {
+ BottomMenu(
+ positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
+ onPositiveButtonClicked = onEnterRecoveryKey,
+ negativeButtonTitle = stringResource(R.string.screen_identity_confirmation_create_new_recovery_key),
+ onNegativeButtonClicked = onCreateNewRecoveryKey,
+ )
} else {
- R.string.screen_session_verification_they_match
+ BottomMenu(
+ positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device),
+ onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
+ negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
+ onNegativeButtonClicked = onEnterRecoveryKey,
+ )
}
}
- FlowStep.Ready -> CommonStrings.action_start
- else -> null
- }
- val negativeButtonTitle = when (verificationViewState) {
- is FlowStep.Initial -> CommonStrings.action_cancel
- FlowStep.Canceled -> CommonStrings.action_cancel
- is FlowStep.Verifying -> R.string.screen_session_verification_they_dont_match
- else -> null
- }
- val negativeButtonEnabled = !isVerifying
-
- val positiveButtonEvent = when (verificationViewState) {
- is FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification
- FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification
- is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null
- FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart
- else -> null
- }
-
- val negativeButtonCallback: () -> Unit = when (verificationViewState) {
- is FlowStep.Verifying -> {
- { eventSink(VerifySelfSessionViewEvents.DeclineVerification) }
+ is FlowStep.Canceled -> {
+ BottomMenu(
+ positiveButtonTitle = stringResource(R.string.screen_session_verification_positive_button_canceled),
+ onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
+ negativeButtonTitle = stringResource(CommonStrings.action_cancel),
+ onNegativeButtonClicked = goBack,
+ )
}
- else -> goBack
+ is FlowStep.Ready -> {
+ BottomMenu(
+ positiveButtonTitle = stringResource(CommonStrings.action_start),
+ onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
+ negativeButtonTitle = stringResource(CommonStrings.action_cancel),
+ onNegativeButtonClicked = goBack,
+ )
+ }
+ is FlowStep.AwaitingOtherDeviceResponse -> {
+ BottomMenu(
+ positiveButtonTitle = stringResource(R.string.screen_identity_waiting_on_other_device),
+ onPositiveButtonClicked = {},
+ isLoading = true,
+ )
+ }
+ is FlowStep.Verifying -> {
+ val positiveButtonTitle = if (isVerifying) {
+ stringResource(R.string.screen_session_verification_positive_button_verifying_ongoing)
+ } else {
+ stringResource(R.string.screen_session_verification_they_match)
+ }
+ BottomMenu(
+ positiveButtonTitle = positiveButtonTitle,
+ onPositiveButtonClicked = {
+ if (!isVerifying) {
+ eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
+ }
+ },
+ negativeButtonTitle = stringResource(R.string.screen_session_verification_they_dont_match),
+ onNegativeButtonClicked = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
+ isLoading = isVerifying,
+ )
+ }
+ is FlowStep.Completed -> {
+ BottomMenu(
+ positiveButtonTitle = stringResource(CommonStrings.action_continue),
+ onPositiveButtonClicked = onFinished,
+ )
+ }
+ is FlowStep.Skipped -> return
}
+}
+@Composable
+private fun BottomMenu(
+ positiveButtonTitle: String?,
+ onPositiveButtonClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+ negativeButtonTitle: String? = null,
+ negativeButtonEnabled: Boolean = negativeButtonTitle != null,
+ onNegativeButtonClicked: () -> Unit = {},
+ isLoading: Boolean = false,
+) {
ButtonColumnMolecule(
- modifier = Modifier.padding(bottom = 20.dp)
+ modifier = modifier.padding(bottom = 16.dp)
) {
if (positiveButtonTitle != null) {
Button(
- text = stringResource(positiveButtonTitle),
- showProgress = isVerifying,
+ text = positiveButtonTitle,
+ showProgress = isLoading,
modifier = Modifier.fillMaxWidth(),
- onClick = { positiveButtonEvent?.let { eventSink(it) } }
+ onClick = onPositiveButtonClicked,
)
}
if (negativeButtonTitle != null) {
TextButton(
- text = stringResource(negativeButtonTitle),
+ text = negativeButtonTitle,
modifier = Modifier.fillMaxWidth(),
- onClick = negativeButtonCallback,
+ onClick = onNegativeButtonClicked,
enabled = negativeButtonEnabled,
)
- }
- if (verificationViewState is FlowStep.Initial && verificationViewState.canEnterRecoveryKey) {
- Text(
- text = stringResource(id = CommonStrings.common_or),
- color = ElementTheme.colors.textSecondary,
- )
- TextButton(
- text = stringResource(R.string.screen_session_verification_enter_recovery_key),
- modifier = Modifier.fillMaxWidth(),
- onClick = onEnterRecoveryKey,
- )
+ } else {
+ Spacer(modifier = Modifier.height(48.dp))
}
}
}
@@ -292,6 +346,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = {},
- goBack = {},
+ onCreateNewRecoveryKey = {},
+ onFinished = {},
)
}
diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt
index 8f9c69085c..2c6b776f7b 100644
--- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt
+++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt
@@ -19,8 +19,9 @@ package io.element.android.features.verifysession.impl
sealed interface VerifySelfSessionViewEvents {
data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents
- data object Restart : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents
data object DeclineVerification : VerifySelfSessionViewEvents
- data object CancelAndClose : VerifySelfSessionViewEvents
+ data object Cancel : VerifySelfSessionViewEvents
+ data object Reset : VerifySelfSessionViewEvents
+ data object SkipVerification : VerifySelfSessionViewEvents
}
diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml
deleted file mode 100644
index 8ae6dd30fa..0000000000
--- a/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml
deleted file mode 100644
index 82583a4011..0000000000
--- a/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml
deleted file mode 100644
index 5b9f2e3cfc..0000000000
--- a/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml
deleted file mode 100644
index 882ac62cd7..0000000000
--- a/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/features/verifysession/impl/src/main/res/values-be/translations.xml b/features/verifysession/impl/src/main/res/values-be/translations.xml
index 57786fec8d..8b2c5e920d 100644
--- a/features/verifysession/impl/src/main/res/values-be/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-be/translations.xml
@@ -1,5 +1,12 @@
+ "Стварыць новы ключ аднаўлення"
+ "Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі."
+ "Пацвердзіце, што гэта вы"
+ "Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе."
+ "Прылада праверана"
+ "Выкарыстоўвайце іншую прыладу"
+ "Чаканне на іншай прыладзе…"
"Здаецца, нешта не так. Альбо час чакання запыту скончыўся, альбо запыт быў адхілены."
"Пераканайцеся, што прыведзеныя ніжэй эмодзі супадаюць з эмодзі, паказанымі ў вашым іншым сеансе."
"Параўнайце эмодзі"
diff --git a/features/verifysession/impl/src/main/res/values-cs/translations.xml b/features/verifysession/impl/src/main/res/values-cs/translations.xml
index 3dd49ce9f2..fadcba869c 100644
--- a/features/verifysession/impl/src/main/res/values-cs/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-cs/translations.xml
@@ -1,5 +1,12 @@
+ "Vytvoření nového klíče pro obnovení"
+ "Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv."
+ "Potvrďte, že jste to vy"
+ "Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat."
+ "Zařízení ověřeno"
+ "Použít jiné zařízení"
+ "Čekání na jiném zařízení…"
"Něco není v pořádku. Buď vypršel časový limit požadavku, nebo byl požadavek zamítnut."
"Zkontrolujte, zda se níže uvedené emotikony shodují s emotikony zobrazenými na jiné relaci."
"Porovnání emotikonů"
diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml
index dd308b215d..baad607403 100644
--- a/features/verifysession/impl/src/main/res/values-de/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-de/translations.xml
@@ -1,5 +1,12 @@
+ "Erstelle einen neuen Wiederherstellungsschlüssel"
+ "Verifiziere dieses Gerät, um sicheres Messaging einzurichten."
+ "Bestätige, dass du es bist"
+ "Du kannst nun verschlüsselte Nachrichten lesen oder versenden."
+ "Gerät verifiziert"
+ "Ein anderes Gerät verwenden"
+ "Bitte warten bis das andere Gerät bereit ist."
"Etwas scheint nicht zu stimmen. Entweder ist das Zeitlimit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt."
"Vergewissere dich dass die folgenden Emojis mit denen in deiner anderen Session übereinstimmen."
"Emojis vergleichen"
diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml
index 7b57eba9a6..a37a51f8c5 100644
--- a/features/verifysession/impl/src/main/res/values-fr/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml
@@ -1,5 +1,11 @@
+ "Vérifier cette session pour configurer votre messagerie sécurisée."
+ "Confirmez votre identité"
+ "Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session."
+ "Session vérifiée"
+ "Utiliser une autre session"
+ "En attente d’une autre session…"
"Quelque chose ne va pas. Soit la demande a expiré, soit elle a été refusée."
"Confirmez que les emojis ci-dessous correspondent à ceux affichés sur votre autre session."
"Comparez les émojis"
diff --git a/features/verifysession/impl/src/main/res/values-hu/translations.xml b/features/verifysession/impl/src/main/res/values-hu/translations.xml
index aac80254ac..c31adb9736 100644
--- a/features/verifysession/impl/src/main/res/values-hu/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-hu/translations.xml
@@ -1,5 +1,12 @@
+ "Új helyreállítási kulcs létrehozása"
+ "A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt."
+ "Erősítse meg, hogy Ön az"
+ "Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben."
+ "Eszköz ellenőrizve"
+ "Másik eszköz használata"
+ "Várakozás a másik eszközre…"
"Valami hibásnak tűnik. A kérés vagy időtúllépésre futott, vagy elutasították."
"Erősítse meg, hogy a lenti emodzsik egyeznek-e a másik munkamenetben megjelenítettekkel."
"Emodzsik összehasonlítása"
diff --git a/features/verifysession/impl/src/main/res/values-in/translations.xml b/features/verifysession/impl/src/main/res/values-in/translations.xml
index f498f7a52b..3a3bec959e 100644
--- a/features/verifysession/impl/src/main/res/values-in/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-in/translations.xml
@@ -1,5 +1,12 @@
+ "Buat kunci pemulihan baru"
+ "Verifikasi perangkat ini untuk menyiapkan perpesanan aman."
+ "Konfirmasi bahwa ini Anda"
+ "Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini."
+ "Perangkat terverifikasi"
+ "Gunakan perangkat lain"
+ "Menunggu di perangkat lain…"
"Sepertinya ada yang tidak beres. Entah permintaan sudah habis masa berlakunya atau permintaan ditolak."
"Konfirmasikan bahwa emoji di bawah ini sesuai dengan yang ditampilkan pada sesi Anda yang lain."
"Bandingkan emoji"
diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml
index 6fb227308e..a3b300793d 100644
--- a/features/verifysession/impl/src/main/res/values-it/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-it/translations.xml
@@ -1,5 +1,11 @@
+ "Verifica questo dispositivo per segnare i tuoi messaggi come sicuri."
+ "Conferma la tua identità"
+ "Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo."
+ "Dispositivo verificato"
+ "Usa un altro dispositivo"
+ "In attesa sull\'altro dispositivo…"
"C\'è qualcosa che non va. La richiesta è scaduta o è stata rifiutata."
"Verifica che gli emoji sottostanti corrispondano a quelli mostrati nell\'altra sessione."
"Confronta le emoji"
diff --git a/features/verifysession/impl/src/main/res/values-ru/translations.xml b/features/verifysession/impl/src/main/res/values-ru/translations.xml
index 69311e39e7..c89077d172 100644
--- a/features/verifysession/impl/src/main/res/values-ru/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-ru/translations.xml
@@ -1,12 +1,25 @@
+
+ "Создайте новый "
+ "ключ восстановления"
+
+ "Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями."
+ "Подтвердите, что это вы"
+ "Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."
+ "Устройство проверено"
+ "Используйте другое устройство"
+ "Ожидание на другом устройстве…"
"Похоже, что-то не так. Время ожидания запроса либо истекло, либо запрос был отклонен."
"Убедитесь, что приведенные ниже емоджи совпадают с емоджи показанными во время другого сеанса."
"Сравните емодзи"
"Убедитесь, что приведенные ниже числа совпадают с цифрами, показанными в другом сеансе."
"Сравните числа"
"Ваш новый сеанс подтвержден. У него есть доступ к вашим зашифрованным сообщениям, и другие пользователи увидят его как доверенное."
- "Введите ключ восстановления"
+
+ "Введите "
+ "ключ восстановления"
+
"Чтобы получить доступ к зашифрованной истории сообщений, докажите, что это вы."
"Открыть существующий сеанс"
"Повторить проверку"
diff --git a/features/verifysession/impl/src/main/res/values-sk/translations.xml b/features/verifysession/impl/src/main/res/values-sk/translations.xml
index 6452b85fcb..b3089b7b30 100644
--- a/features/verifysession/impl/src/main/res/values-sk/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-sk/translations.xml
@@ -1,5 +1,12 @@
+ "Vytvoriť nový kľúč na obnovenie"
+ "Ak chcete nastaviť zabezpečené správy, overte toto zariadenie."
+ "Potvrďte, že ste to vy"
+ "Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete."
+ "Zariadenie overené"
+ "Použiť iné zariadenie"
+ "Čaká sa na druhom zariadení…"
"Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá."
"Skontrolujte, či sa emotikony uvedené nižšie zhodujú s emotikonmi zobrazenými vo vašej druhej relácii."
"Porovnajte emotikony"
diff --git a/features/verifysession/impl/src/main/res/values-uk/translations.xml b/features/verifysession/impl/src/main/res/values-uk/translations.xml
index beed211c15..9be10a2bb0 100644
--- a/features/verifysession/impl/src/main/res/values-uk/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-uk/translations.xml
@@ -1,5 +1,9 @@
+ "Перевірте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."
+ "Підтвердіть, що це ви"
+ "Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."
+ "Пристрій перевірено"
"Щось не так. Або час очікування запиту минув, або в запиті було відмовлено."
"Переконайтеся, що емодзі нижче збігаються з тими, що відображаються під час іншого сеансу."
"Порівняти емодзі"
diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
index ed85823769..d73ea8e2c4 100644
--- a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,5 +1,8 @@
+ "裝置已認證"
+ "使用另一個裝置"
+ "正在等待其他裝置……"
"似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。"
"確認顯示在其他工作階段上的表情符號是否和下方的相同。"
"比對表情符號"
diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml
index b46954f42b..41cd2f8dbc 100644
--- a/features/verifysession/impl/src/main/res/values/localazy.xml
+++ b/features/verifysession/impl/src/main/res/values/localazy.xml
@@ -1,5 +1,12 @@
+ "Create a new recovery key"
+ "Verify this device to set up secure messaging."
+ "Confirm that it\'s you"
+ "Now you can read or send messages securely, and anyone you chat with can also trust this device."
+ "Device verified"
+ "Use another device"
+ "Waiting on other device…"
"Something doesn’t seem right. Either the request timed out or the request was denied."
"Confirm that the emojis below match those shown on your other session."
"Compare emojis"
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
index ad128b450d..06f69e1628 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt
@@ -23,15 +23,19 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.core.meta.BuildMeta
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.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
+import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -48,7 +52,21 @@ class VerifySelfSessionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ awaitItem().run {
+ assertThat(verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ assertThat(displaySkipButton).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `present - hides skip verification button on non-debuggable builds`() = runTest {
+ val buildMeta = aBuildMeta(isDebuggable = false)
+ val presenter = createVerifySelfSessionPresenter(buildMeta = buildMeta)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ assertThat(awaitItem().displaySkipButton).isFalse()
}
}
@@ -62,13 +80,28 @@ class VerifySelfSessionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true))
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true, false))
+ }
+ }
+
+ @Test
+ fun `present - Initial state is received, can use recovery key and is last device`() = runTest {
+ val presenter = createVerifySelfSessionPresenter(
+ encryptionService = FakeEncryptionService().apply {
+ emitIsLastDevice(true)
+ emitRecoveryState(RecoveryState.INCOMPLETE)
+ }
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(true, true))
}
}
@Test
fun `present - Handles requestVerification`() = runTest {
- val service = FakeSessionVerificationService()
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -79,13 +112,13 @@ class VerifySelfSessionPresenterTests {
@Test
fun `present - Handles startSasVerification`() = runTest {
- val service = FakeSessionVerificationService()
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response:
@@ -104,16 +137,16 @@ class VerifySelfSessionPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
- assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
val eventSink = initialState.eventSink
- eventSink(VerifySelfSessionViewEvents.CancelAndClose)
+ eventSink(VerifySelfSessionViewEvents.Cancel)
expectNoEvents()
}
}
@Test
- fun `present - A fail in the flow cancels it`() = runTest {
- val service = FakeSessionVerificationService()
+ fun `present - A failure when verifying cancels it`() = runTest {
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -128,23 +161,37 @@ class VerifySelfSessionPresenterTests {
}
}
+ @Test
+ fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
+ val service = unverifiedSessionService()
+ val presenter = createVerifySelfSessionPresenter(service)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ service.shouldFail = true
+ awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
+ service.shouldFail = false
+ assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ }
+ }
+
@Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
- val service = FakeSessionVerificationService()
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
- state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
- assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
+ state.eventSink(VerifySelfSessionViewEvents.Cancel)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
}
}
@Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
- val service = FakeSessionVerificationService()
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -157,7 +204,7 @@ class VerifySelfSessionPresenterTests {
@Test
fun `present - Restart after cancelation returns to requesting verification`() = runTest {
- val service = FakeSessionVerificationService()
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -165,19 +212,36 @@ class VerifySelfSessionPresenterTests {
val state = requestVerificationAndAwaitVerifyingState(service)
service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
- state.eventSink(VerifySelfSessionViewEvents.Restart)
+ state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Went back to requesting verification
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents()
}
}
+ @Test
+ fun `present - Go back after cancelation returns to initial state`() = runTest {
+ val service = unverifiedSessionService()
+ val presenter = createVerifySelfSessionPresenter(service)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val state = requestVerificationAndAwaitVerifyingState(service)
+ service.givenVerificationFlowState(VerificationFlowState.Canceled)
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
+ state.eventSink(VerifySelfSessionViewEvents.Reset)
+ // Went back to initial state
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun `present - When verification is approved, the flow completes if there is no error`() = runTest {
val emojis = listOf(
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
)
- val service = FakeSessionVerificationService()
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -199,7 +263,7 @@ class VerifySelfSessionPresenterTests {
@Test
fun `present - When verification is declined, the flow is canceled`() = runTest {
- val service = FakeSessionVerificationService()
+ val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -216,12 +280,39 @@ class VerifySelfSessionPresenterTests {
}
}
+ @Test
+ fun `present - Skip event skips the flow`() = runTest {
+ val service = unverifiedSessionService()
+ val presenter = createVerifySelfSessionPresenter(service)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val state = requestVerificationAndAwaitVerifyingState(service)
+ state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
+ service.saveVerifiedStateResult.assertions().isCalledOnce().with(value(true))
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Skipped)
+ }
+ }
+
+ @Test
+ fun `present - When verification is not needed, the flow is completed`() = runTest {
+ val service = FakeSessionVerificationService().apply {
+ givenNeedsVerification(false)
+ }
+ val presenter = createVerifySelfSessionPresenter(service)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
+ }
+ }
+
private suspend fun ReceiveTurbine.requestVerificationAndAwaitVerifyingState(
fakeService: FakeSessionVerificationService,
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState {
var state = awaitItem()
- assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
+ assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
state = awaitItem()
@@ -240,14 +331,23 @@ class VerifySelfSessionPresenterTests {
return state
}
+ private fun unverifiedSessionService(): FakeSessionVerificationService {
+ return FakeSessionVerificationService().apply {
+ givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
+ givenNeedsVerification(true)
+ }
+ }
+
private fun createVerifySelfSessionPresenter(
- service: SessionVerificationService = FakeSessionVerificationService(),
+ service: SessionVerificationService = unverifiedSessionService(),
encryptionService: EncryptionService = FakeEncryptionService(),
+ buildMeta: BuildMeta = aBuildMeta(),
): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(
sessionVerificationService = service,
encryptionService = encryptionService,
- stateMachine = VerifySelfSessionStateMachine(service),
+ stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
+ buildMeta = buildMeta,
)
}
}
diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
index 4dfad8c9c9..4d5f67f0b1 100644
--- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
+++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt
@@ -17,17 +17,20 @@
package io.element.android.features.verifysession.impl
import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@@ -36,57 +39,101 @@ class VerifySelfSessionViewTest {
@get:Rule val rule = createAndroidComposeRule()
@Test
- fun `clicking on cancel calls the expected callback and emits the expected Event`() {
+ fun `back key pressed - when canceled resets the flow`() {
val eventsRecorder = EventsRecorder()
- ensureCalledOnce { callback ->
- rule.setContent {
- VerifySelfSessionView(
- aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
- eventSink = eventsRecorder
- ),
- onEnterRecoveryKey = EnsureNeverCalled(),
- goBack = callback,
- )
- }
- rule.clickOn(CommonStrings.action_cancel)
- }
- eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose)
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
}
@Test
- fun `clicking on back key calls the expected callback and emits the expected Event`() {
+ fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder()
- ensureCalledOnce { callback ->
- rule.setContent {
- VerifySelfSessionView(
- aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
- eventSink = eventsRecorder
- ),
- onEnterRecoveryKey = EnsureNeverCalled(),
- goBack = callback,
- )
- }
- rule.pressBackKey()
- }
- eventsRecorder.assertSingle(VerifySelfSessionViewEvents.CancelAndClose)
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
}
@Test
- fun `when flow is completed, the expected callback is invoked`() {
+ fun `back key pressed - when ready to verify cancels the verification`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
+ }
+
+ @Test
+ fun `back key pressed - when verifying and not loading declines the verification`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ data = aEmojisSessionVerificationData(),
+ state = AsyncData.Uninitialized,
+ ),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
+ }
+
+ @Test
+ fun `back key pressed - when verifying and loading does nothing`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ data = aEmojisSessionVerificationData(),
+ state = AsyncData.Loading(),
+ ),
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertEmpty()
+ }
+
+ @Test
+ fun `back key pressed - on Completed step does nothing`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.pressBackKey()
+ eventsRecorder.assertEmpty()
+ }
+
+ @Test
+ fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setContent {
- VerifySelfSessionView(
- aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
- eventSink = eventsRecorder
- ),
- onEnterRecoveryKey = EnsureNeverCalled(),
- goBack = callback,
- )
- }
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
+ eventSink = eventsRecorder
+ ),
+ onFinished = callback,
+ )
+ rule.clickOn(CommonStrings.action_continue)
}
}
@@ -95,36 +142,45 @@ class VerifySelfSessionViewTest {
fun `clicking on enter recovery key calls the expected callback`() {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setContent {
- VerifySelfSessionView(
- aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
- eventSink = eventsRecorder
- ),
- onEnterRecoveryKey = callback,
- goBack = EnsureNeverCalled(),
- )
- }
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true, false),
+ eventSink = eventsRecorder
+ ),
+ onEnterRecoveryKey = callback,
+ )
rule.clickOn(R.string.screen_session_verification_enter_recovery_key)
}
}
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `clicking on create new recovery key calls the expected callback`() {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true, true),
+ eventSink = eventsRecorder
+ ),
+ onCreateNewRecoveryKey = callback,
+ )
+ rule.clickOn(R.string.screen_identity_confirmation_create_new_recovery_key)
+ }
+ }
+
@Test
fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder()
- rule.setContent {
- VerifySelfSessionView(
- aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
- data = aEmojisSessionVerificationData(),
- state = AsyncData.Uninitialized,
- ),
- eventSink = eventsRecorder
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ data = aEmojisSessionVerificationData(),
+ state = AsyncData.Uninitialized,
),
- onEnterRecoveryKey = EnsureNeverCalled(),
- goBack = EnsureNeverCalled(),
- )
- }
+ eventSink = eventsRecorder
+ ),
+ )
rule.clickOn(R.string.screen_session_verification_they_match)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.ConfirmVerification)
}
@@ -132,20 +188,60 @@ class VerifySelfSessionViewTest {
@Test
fun `clicking on they do not match emits the expected event`() {
val eventsRecorder = EventsRecorder()
- rule.setContent {
- VerifySelfSessionView(
- aVerifySelfSessionState(
- verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
- data = aEmojisSessionVerificationData(),
- state = AsyncData.Uninitialized,
- ),
- eventSink = eventsRecorder
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
+ data = aEmojisSessionVerificationData(),
+ state = AsyncData.Uninitialized,
),
- onEnterRecoveryKey = EnsureNeverCalled(),
- goBack = EnsureNeverCalled(),
- )
- }
+ eventSink = eventsRecorder
+ ),
+ )
rule.clickOn(R.string.screen_session_verification_they_dont_match)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
}
+
+ @Test
+ fun `clicking on 'Skip' emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(canEnterRecoveryKey = true, isLastDevice = false),
+ displaySkipButton = true,
+ eventSink = eventsRecorder
+ ),
+ )
+ rule.clickOn(CommonStrings.action_skip)
+ eventsRecorder.assertSingle(VerifySelfSessionViewEvents.SkipVerification)
+ }
+
+ @Test
+ fun `on Skipped step - onFinished callback is called immediately`() {
+ ensureCalledOnce { callback ->
+ rule.setVerifySelfSessionView(
+ aVerifySelfSessionState(
+ verificationFlowStep = VerifySelfSessionState.VerificationStep.Skipped,
+ displaySkipButton = true,
+ eventSink = EnsureNeverCalledWithParam(),
+ ),
+ onFinished = callback,
+ )
+ }
+ }
+
+ private fun AndroidComposeTestRule.setVerifySelfSessionView(
+ state: VerifySelfSessionState,
+ onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
+ onCreateNewRecoveryKey: () -> Unit = EnsureNeverCalled(),
+ onFinished: () -> Unit = EnsureNeverCalled(),
+ ) {
+ rule.setContent {
+ VerifySelfSessionView(
+ state = state,
+ onEnterRecoveryKey = onEnterRecoveryKey,
+ onCreateNewRecoveryKey = onCreateNewRecoveryKey,
+ onFinished = onFinished,
+ )
+ }
+ }
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ccf78465d3..ac8dd451dc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,7 +3,7 @@
[versions]
# Project
-android_gradle_plugin = "8.3.1"
+android_gradle_plugin = "8.3.2"
kotlin = "1.9.23"
ksp = "1.9.23-1.0.19"
firebaseAppDistribution = "4.2.0"
@@ -18,7 +18,7 @@ activity = "1.8.2"
media3 = "1.3.0"
# Compose
-compose_bom = "2024.03.00"
+compose_bom = "2024.04.00"
composecompiler = "1.5.11"
# Coroutines
@@ -33,16 +33,16 @@ test_core = "1.5.0"
#other
coil = "2.6.0"
datetime = "0.5.0"
-dependencyAnalysis = "1.30.0"
+dependencyAnalysis = "1.31.0"
serialization_json = "1.6.3"
showkase = "1.0.2"
appyx = "1.4.0"
-sqldelight = "2.0.1"
-wysiwyg = "2.34.0"
+sqldelight = "2.0.2"
+wysiwyg = "2.36.0"
telephoto = "0.9.0"
# DI
-dagger = "2.51"
+dagger = "2.51.1"
anvil = "2.4.9"
# Auto service
@@ -120,7 +120,7 @@ network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" }
network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_okhttp = { module = "com.squareup.okhttp3:okhttp" }
network_mockwebserver = { module = "com.squareup.okhttp3:mockwebserver" }
-network_retrofit_bom = "com.squareup.retrofit2:retrofit-bom:2.10.0"
+network_retrofit_bom = "com.squareup.retrofit2:retrofit-bom:2.11.0"
network_retrofit = { module = "com.squareup.retrofit2:retrofit" }
network_retrofit_converter_serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization" }
@@ -136,7 +136,7 @@ test_konsist = "com.lemonappdev:konsist:0.13.0"
test_turbine = "app.cash.turbine:turbine:1.1.0"
test_truth = "com.google.truth:truth:1.4.2"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.15"
-test_robolectric = "org.robolectric:robolectric:4.11.1"
+test_robolectric = "org.robolectric:robolectric:4.12.1"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
# Others
@@ -144,7 +144,7 @@ coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
-compound = { module = "io.element.android:compound-android", version = "0.0.5" }
+compound = { module = "io.element.android:compound-android", version = "0.0.6" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7"
@@ -152,9 +152,9 @@ showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = "org.jsoup:jsoup:1.17.2"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
-molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.1"
+molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.2"
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.12"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.13"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@@ -175,9 +175,11 @@ opusencoder = "io.element.android:opusencoder:1.1.0"
kotlinpoet = "com.squareup:kotlinpoet:1.16.0"
# Analytics
-posthog = "com.posthog:posthog-android:3.1.15"
-sentry = "io.sentry:sentry-android:7.6.0"
-matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.14.0"
+posthog = "com.posthog:posthog-android:3.1.16"
+sentry = "io.sentry:sentry-android:7.8.0"
+# Note: only 0.19.0 will compile properly
+# main branch can be tested replacing the version with main-SNAPSHOT
+matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.15.0"
# Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3"
@@ -217,7 +219,7 @@ anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
detekt = "io.gitlab.arturbosch.detekt:1.23.6"
ktlint = "org.jlleitschuh.gradle.ktlint:12.1.0"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
-dependencycheck = "org.owasp.dependencycheck:9.0.10"
+dependencycheck = "org.owasp.dependencycheck:9.1.0"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
paparazzi = "app.cash.paparazzi:1.3.3"
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index d64cd49177..e6441136f3 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 865f1ba80d..fcbbad6dd6 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=85719317abd2112f021d4f41f09ec370534ba288432065f4b477b6a3b652910d
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip
+distributionSha256Sum=194717442575a6f96e1c1befa2c30e9a4fc90f701d7aee33eb879b79e7ff05c0
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew.bat b/gradlew.bat
index 6689b85bee..7101f8e467 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -43,11 +43,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
index 422307ffd9..737eab7ac7 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.androidutils.system
+import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@@ -73,6 +74,9 @@ fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResu
val intent = Intent()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
+ if (this !is Activity && activityResultLauncher == null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
} else {
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
@@ -154,6 +158,9 @@ fun Context.openUrlInExternalApp(
errorMessage: String = getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ if (this !is Activity) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
try {
startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt
index 24296f8798..b0d3f3a339 100644
--- a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt
+++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt
@@ -31,9 +31,10 @@ class InviteFriendsUseCase @Inject constructor(
private val stringProvider: StringProvider,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
+ private val permalinkBuilder: PermalinkBuilder,
) {
fun execute(activity: Activity) {
- val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId)
+ val permalinkResult = permalinkBuilder.permalinkForUser(matrixClient.sessionId)
permalinkResult.fold(
onSuccess = { permalink ->
val appName = buildMeta.applicationName
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt
index 338ad05349..63a5150531 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt
@@ -34,6 +34,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.modifiers.blurCompat
import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow
@@ -171,6 +172,7 @@ internal fun ElementLogoAtomLargeNoBlurShadowPreview() = ElementPreview {
ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false)
}
+@ExcludeFromCoverage
@Composable
private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) {
Box(
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt
index 1dc7230973..b90cf80aaa 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt
@@ -32,6 +32,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
+import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
@@ -95,3 +96,14 @@ internal fun IconTitleSubtitleMoleculePreview() = ElementPreview {
subTitle = "Subtitle",
)
}
+
+@PreviewsDayNight
+@Composable
+internal fun IconTitleSubtitleMoleculeWithResIconPreview() = ElementPreview {
+ IconTitleSubtitleMolecule(
+ iconResourceId = CompoundDrawables.ic_compound_admin,
+ iconTint = Color.Black,
+ title = "Title",
+ subTitle = "Subtitle",
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt
index f8e0b9baa8..5c25593184 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt
@@ -59,8 +59,8 @@ fun InfoListItemMolecule(
color = backgroundColor,
shape = backgroundShape,
)
- .padding(vertical = 12.dp, horizontal = 20.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp),
+ .padding(vertical = 12.dp, horizontal = 18.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
icon()
message()
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt
index 6b99537dc9..b2cf88b8bc 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt
@@ -34,6 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
/**
* @param modifier Classical modifier.
+ * @param background optional background component.
* @param topBar optional topBar.
* @param header optional header.
* @param footer optional footer.
@@ -42,6 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun HeaderFooterPage(
modifier: Modifier = Modifier,
+ background: @Composable () -> Unit = {},
topBar: @Composable () -> Unit = {},
header: @Composable () -> Unit = {},
footer: @Composable () -> Unit = {},
@@ -51,25 +53,28 @@ fun HeaderFooterPage(
modifier = modifier,
topBar = topBar,
) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- .padding(all = 20.dp),
- ) {
- // Header
- header()
- // Content
+ Box {
+ background()
Column(
modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
+ .padding(all = 20.dp)
+ .padding(padding)
+ .consumeWindowInsets(padding)
) {
- content()
- }
- // Footer
- Box(modifier = Modifier.padding(horizontal = 16.dp)) {
- footer()
+ // Header
+ header()
+ // Content
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ ) {
+ content()
+ }
+ // Footer
+ Box(modifier = Modifier.padding(horizontal = 16.dp)) {
+ footer()
+ }
}
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/OnboardingBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/OnboardingBackground.kt
new file mode 100644
index 0000000000..5dbced7417
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/OnboardingBackground.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.components
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.LinearGradientShader
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+
+/**
+ * Gradient background for FTUE (onboarding) screens.
+ */
+@Suppress("ModifierMissing")
+@Composable
+fun OnboardingBackground() {
+ Box(modifier = Modifier.fillMaxSize()) {
+ val isLightTheme = ElementTheme.isLightTheme
+ Canvas(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp)
+ .align(Alignment.BottomCenter)
+ ) {
+ val gradientBrush = ShaderBrush(
+ LinearGradientShader(
+ from = Offset(0f, size.height / 2f),
+ to = Offset(size.width, size.height / 2f),
+ colors = listOf(
+ Color(0xFF0DBDA8),
+ if (isLightTheme) Color(0xC90D5CBD) else Color(0xFF0D5CBD),
+ )
+ )
+ )
+ val eraseBrush = ShaderBrush(
+ LinearGradientShader(
+ from = Offset(size.width / 2f, 0f),
+ to = Offset(size.width / 2f, size.height * 2f),
+ colors = listOf(
+ Color(0xFF000000),
+ Color(0x00000000),
+ )
+ )
+ )
+ drawWithLayer {
+ drawRect(brush = gradientBrush, size = size)
+ drawRect(brush = gradientBrush, size = size, blendMode = BlendMode.Overlay)
+ drawRect(brush = eraseBrush, size = size, blendMode = BlendMode.DstOut)
+ }
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun OnboardingBackgroundPreview() {
+ ElementPreview {
+ OnboardingBackground()
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt
index 9ff8ef38da..de1e25f5f3 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PageTitle.kt
@@ -52,9 +52,7 @@ fun PageTitle(
callToAction: @Composable (() -> Unit)? = null,
) {
Column(
- modifier = modifier
- .fillMaxWidth()
- .padding(bottom = 40.dp),
+ modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BigIcon(style = iconStyle)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt
index b1e7bc6be0..173716aee3 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt
@@ -23,16 +23,21 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
+import coil.compose.AsyncImagePainter
+import coil.compose.SubcomposeAsyncImage
+import coil.compose.SubcomposeAsyncImageContent
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
@@ -71,16 +76,34 @@ private fun ImageAvatar(
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
- AsyncImage(
- model = avatarData,
- onError = {
- Timber.e(it.result.throwable, "Error loading avatar $it\n${it.result}")
- },
- contentDescription = contentDescription,
- contentScale = ContentScale.Crop,
- placeholder = debugPlaceholderAvatar(),
- modifier = modifier
- )
+ if (LocalInspectionMode.current) {
+ // For compose previews, use debugPlaceholderAvatar()
+ // instead of falling back to initials avatar on load failure
+ AsyncImage(
+ model = avatarData,
+ contentDescription = contentDescription,
+ placeholder = debugPlaceholderAvatar(),
+ modifier = modifier
+ )
+ } else {
+ SubcomposeAsyncImage(
+ model = avatarData,
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Crop,
+ modifier = modifier
+ ) {
+ when (val state = painter.state) {
+ is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
+ is AsyncImagePainter.State.Error -> {
+ SideEffect {
+ Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
+ }
+ InitialsAvatar(avatarData = avatarData)
+ }
+ else -> InitialsAvatar(avatarData = avatarData)
+ }
+ }
+ }
}
@Composable
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
index 0451cb5839..2dc6c8875f 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
@@ -51,5 +51,7 @@ enum class AvatarSize(val dp: Dp) {
NotificationsOptIn(32.dp),
- CustomRoomNotificationSetting(36.dp)
+ CustomRoomNotificationSetting(36.dp),
+
+ RoomDirectoryItem(36.dp),
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt
index 33f5e3b6bb..e8a859b54a 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt
@@ -36,6 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -162,6 +163,7 @@ internal fun PreferenceTextWithEndBadgeDarkPreview() = ElementPreviewDark {
ContentToPreview(showEndBadge = true)
}
+@ExcludeFromCoverage
@Composable
private fun ContentToPreview(showEndBadge: Boolean) {
Column(
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt
deleted file mode 100644
index 3f9ea0040e..0000000000
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.libraries.designsystem.modifiers
-
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.updateTransition
-import androidx.compose.runtime.State
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
-import androidx.compose.ui.draw.drawWithCache
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Rect
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.drawscope.clipPath
-import androidx.compose.ui.platform.debugInspectorInfo
-import kotlin.math.sqrt
-
-// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
-
-/**
- * A modifier that clips the composable content using an animated circle. The circle will
- * expand/shrink with an animation whenever [visible] changes.
- *
- * For more fine-grained control over the transition, see this method's overload, which allows passing
- * a [State] object to control the progress of the reveal animation.
- *
- * By default, the circle is centered in the content, but custom positions may be specified using
- * [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/
-fun Modifier.circularReveal(
- visible: Boolean,
- showScrim: Boolean = false,
- revealFrom: Offset = Offset(0.5f, 0.5f),
-): Modifier = composed(
- factory = {
- val factor = updateTransition(visible, label = "Visibility")
- .animateFloat(label = "revealFactor") { if (it) 1f else 0f }
-
- circularReveal(factor, showScrim, revealFrom)
- },
- inspectorInfo = debugInspectorInfo {
- name = "circularReveal"
- properties["visible"] = visible
- properties["revealFrom"] = revealFrom
- }
-)
-
-/**
- * A modifier that clips the composable content using a circular shape. The radius of the circle
- * will be determined by the [transitionProgress].
- *
- * The values of the progress should be between 0 and 1.
- *
- * By default, the circle is centered in the content, but custom positions may be specified using
- * [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
- * */
-fun Modifier.circularReveal(
- transitionProgress: State,
- showScrim: Boolean = false,
- revealFrom: Offset = Offset(0.5f, 0.5f)
-): Modifier {
- return drawWithCache {
- val path = Path()
- val center = revealFrom.mapTo(size)
- val radius = calculateRadius(revealFrom, size)
- val scrimColor = if (showScrim) {
- Color.Gray
- } else {
- Color.Transparent
- }
-
- path.addOval(Rect(center, radius * transitionProgress.value))
-
- onDrawWithContent {
- if (showScrim) {
- drawRect(scrimColor, alpha = transitionProgress.value * 0.75f)
- }
- clipPath(path) { this@onDrawWithContent.drawContent() }
- }
- }
-}
-
-private fun Offset.mapTo(size: Size): Offset {
- return Offset(x * size.width, y * size.height)
-}
-
-private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
- val x = (if (x > 0.5f) x else 1 - x) * size.width
- val y = (if (y > 0.5f) y else 1 - y) * size.height
-
- sqrt(x * x + y * y)
-}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/SquareSizeModifier.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/SquareSizeModifier.kt
new file mode 100644
index 0000000000..f2b7c49cf4
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/SquareSizeModifier.kt
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.designsystem.modifiers
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Makes the content square in size.
+ *
+ * This is achieved by cropping incoming max constraints to the largest possible square size
+ * and measuring the content using resulting constraints.
+ * Next the size of layout is decided based on largest dimension of the measured content.
+ * Finally the content is placed inside the square layout according to specified [position].
+ *
+ * If no square exists that falls within the size range of the incoming constraints,
+ * the content will be laid out as usual, as if the modifier was not applied.
+ *
+ * @param position The fraction of the content's position inside its square layout.
+ * It determines the point on the axis that was extended to make a square.
+ * Typically you'd want to use values between `0` and `1`, inclusive, where `0`
+ * will place the content at the "start" of the square, `0.5` in the middle, and `1` at the "end".
+ */
+@Stable
+fun Modifier.squareSize(
+ position: Float = 0.5f,
+): Modifier =
+ this.then(
+ when {
+ position == 0.5f -> SquareSizeCenter
+ else -> createSquareSizeModifier(position = position)
+ }
+ )
+
+private val SquareSizeCenter = createSquareSizeModifier(position = 0.5f)
+
+private class SquareSizeModifier(
+ private val position: Float,
+ inspectorInfo: InspectorInfo.() -> Unit,
+) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
+ override fun MeasureScope.measure(
+ measurable: Measurable,
+ constraints: Constraints,
+ ): MeasureResult {
+ val maxSquare = min(constraints.maxWidth, constraints.maxHeight)
+ val minSquare = max(constraints.minWidth, constraints.minHeight)
+ val squareExists = minSquare <= maxSquare
+
+ val resolvedConstraints = constraints
+ .takeUnless { squareExists }
+ ?: constraints.copy(maxWidth = maxSquare, maxHeight = maxSquare)
+
+ val placeable = measurable.measure(resolvedConstraints)
+
+ return if (squareExists) {
+ val size = max(placeable.width, placeable.height)
+ layout(size, size) {
+ val x = ((size - placeable.width) * position).toInt()
+ val y = ((size - placeable.height) * position).toInt()
+ placeable.placeRelative(x, y)
+ }
+ } else {
+ layout(placeable.width, placeable.height) {
+ placeable.placeRelative(0, 0)
+ }
+ }
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ if (other !is SquareSizeModifier) return false
+
+ if (position != other.position) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return position.hashCode()
+ }
+}
+
+@Suppress("ModifierFactoryExtensionFunction", "ModifierFactoryReturnType")
+private fun createSquareSizeModifier(
+ position: Float,
+) =
+ SquareSizeModifier(
+ position = position,
+ inspectorInfo = debugInspectorInfo {
+ name = "squareSize"
+ properties["position"] = position
+ },
+ )
+
+@Preview
+@Composable
+internal fun SquareSizeModifierLargeWidthPreview() {
+ ElementPreview {
+ Box(
+ modifier = Modifier
+ .padding(32.dp)
+ .background(Color.Gray)
+ .squareSize(position = 0.25f)
+ ) {
+ Box(
+ modifier = Modifier
+ .background(Color.Black)
+ .size(100.dp, 10.dp)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+internal fun SquareSizeModifierLargeHeightPreview() {
+ ElementPreview {
+ Box(
+ modifier = Modifier
+ .padding(32.dp)
+ .background(Color.Gray)
+ .squareSize(position = 0.75f)
+ ) {
+ Box(
+ modifier = Modifier
+ .background(Color.Black)
+ .size(10.dp, 100.dp)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+internal fun SquareSizeModifierInsideSquarePreview() {
+ ElementPreview {
+ Box(
+ modifier = Modifier
+ .padding(32.dp)
+ .size(120.dp)
+ .background(Color.Gray),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .background(Color.Black)
+ .width(100.dp)
+ .squareSize(position = 0.75f)
+ )
+ }
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt
index 42289ad783..bddf614e68 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@@ -269,6 +270,7 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
+@ExcludeFromCoverage
private fun ContentToPreview(
query: String = "",
active: Boolean = false,
diff --git a/libraries/designsystem/src/main/res/drawable/ic_winner.xml b/libraries/designsystem/src/main/res/drawable/ic_winner.xml
new file mode 100644
index 0000000000..20754ecfe8
--- /dev/null
+++ b/libraries/designsystem/src/main/res/drawable/ic_winner.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index e60840768e..ffaabb45ea 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -25,6 +25,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@@ -62,6 +63,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter,
private val stateContentFormatter: StateContentFormatter,
+ private val permalinkParser: PermalinkParser
) : RoomLastMessageFormatter {
companion object {
// Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105
@@ -121,7 +123,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
return "* $senderDisplayName ${messageType.body}"
}
is TextMessageType -> {
- messageType.toPlainText()
+ messageType.toPlainText(permalinkParser)
}
is VideoMessageType -> {
sp.getString(CommonStrings.common_video)
diff --git a/libraries/eventformatter/impl/src/main/res/values-be/translations.xml b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
index f7f1a652ba..00e6e1665e 100644
--- a/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml
@@ -1,32 +1,32 @@
"(аватар таксама быў зменены)"
- "%1$s змяніў аватар"
+ "%1$s змяніў(-ла) аватар"
"Вы змянілі свой аватар"
- "%1$s быў паніжаны да ўдзельніка"
- "%1$s быў паніжаны да мадэратара"
- "%1$s змяніў сваё адлюстраванае імя з %2$s на %3$s"
+ "%1$s быў паніжаны(-на) да ўдзельніка"
+ "%1$s быў паніжаны(-на) да мадэратара"
+ "%1$s змяніў(-ла) сваё адлюстраванае імя з %2$s на %3$s"
"Вы змянілі сваё адлюстраванае імя з %1$s на %2$s"
- "%1$s выдаліў сваё адлюстраванае імя (яно было %2$s)"
+ "%1$s выдаліў(-ла) сваё адлюстраванае імя (яно было %2$s)"
"Вы выдалілі сваё адлюстраванае імя (яно было %1$s)"
"%1$s усталявалі сваё адлюстраванае імя на %2$s"
"Вы ўстанавілі адлюстраванае імя на %1$s"
- "%1$s быў павышаны да адміністратара"
- "%1$s быў павышаны да мадэратара"
- "%1$s змяніў аватар пакоя"
+ "%1$s быў(-ла) павышаны(-на) да адміністратара"
+ "%1$s быў(-ла) павышаны(-на) да мадэратара"
+ "%1$s змяніў(-ла) аватар пакоя"
"Вы змянілі аватар пакоя"
"%1$s выдаліў(-ла) аватар пакоя"
"Вы выдалілі аватар пакоя"
- "%1$s заблакіраваў %2$s"
+ "%1$s заблакіраваў(-ла) %2$s"
"Вы заблакіравалі %1$s"
- "%1$s стварыў пакой"
+ "%1$s стварыў(-ла) пакой"
"Вы стварылі пакой"
- "%1$s запрасіў %2$s"
+ "%1$s запрасіў(-ла) %2$s"
"%1$s прыняў(-ла) запрашэнне"
"Вы прынялі запрашэнне"
"Вы запрасілі %1$s"
- "%1$s запрасіў вас"
- "%1$s далучыўся да пакоя"
+ "%1$s запрасіў(-ла) вас"
+ "%1$s далучыўся(-лась) да пакоя"
"Вы далучыліся да пакоя"
"%1$s прасіў(-ла) далучыцца"
"%1$s дазволіў(-ла) %2$s далучыцца"
@@ -37,17 +37,17 @@
"%1$s адхіліў(-ла) ваш запыт на далучэнне"
"%1$s больш не зацікаўлены(-на) у далучэнні"
"Вы адмянілі запыт на далучэнне"
- "%1$s выйшаў з пакоя"
+ "%1$s выйшаў(-ла) з пакоя"
"Вы выйшлі з пакоя"
- "%1$s змяніў назву пакоя на: %2$s"
+ "%1$s змяніў(-ла) назву пакоя на: %2$s"
"Вы змянілі назву пакоя на: %1$s"
"%1$s выдаліў(-ла) назву пакоя"
"Вы выдалілі назву пакоя"
- "%1$s не зрабіў ніякіх змен"
+ "%1$s не зрабіў(-ла) ніякіх змен"
"Вы не зрабілі ніякіх змен"
- "%1$s адхіліў запрашэнне"
+ "%1$s адхіліў(-ла) запрашэнне"
"Вы адхілілі запрашэнне"
- "%1$s выдаліў %2$s"
+ "%1$s выдаліў(-ла) %2$s"
"Вы выдалілі %1$s"
"%1$s адправіў(-ла) запрашэнне %2$s далучыцца да пакоя"
"Вы адправілі запрашэнне %1$s далучыцца да пакоя"
@@ -57,7 +57,7 @@
"Вы змянілі тэму на: %1$s"
"%1$s выдаліў(-ла) тэму пакоя"
"Вы выдалілі тэму пакоя"
- "%1$s разблакіраваў %2$s"
+ "%1$s разблакіраваў(-ла) %2$s"
"Вы разблакіравалі %1$s"
"%1$s унеслі невядомую змену ў сяброўства"
diff --git a/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml b/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml
index 5aded12d62..5bcb239126 100644
--- a/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml
@@ -16,7 +16,7 @@
"%1$s megváltoztatta a szoba profilképét"
"Megváltoztatta a szoba profilképét"
"%1$s eltávolította a szoba profilképét"
- "Eltávolítottad a szoba profilképét"
+ "Eltávolította a szoba profilképét"
"%1$s kitiltotta: %2$s"
"Kitiltotta: %1$s"
"%1$s létrehozta a szobát"
diff --git a/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml b/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml
index fab8a16e94..eccaa950eb 100644
--- a/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml
@@ -3,16 +3,16 @@
"(аватар теж було змінено)"
"%1$s змінив (-ла) свій аватар"
"Ви змінили свій аватар"
- "%1$s був понижений до члена"
- "%1$s був понижений до модератора"
+ "%1$s понижено до учасника"
+ "%1$s понижено до модератора"
"%1$s змінив (-ла) своє імʼя з %2$s на %3$s"
"Ви змінили своє ім\'я з %1$s на %2$s"
"%1$s видалив (-ла) своє ім\'я (було %2$s)"
"Ви видалили своє ім\'я (було%1$s)"
"%1$s змінив (-ла) своє ім\'я на %2$s"
"Ви змінили своє імʼя на %1$s"
- "%1$s був підвищений до адміністратора"
- "%1$s був підвищений до модератора"
+ "%1$s підвищено до адміністратора"
+ "%1$s підвищено до модератора"
"%1$s змінив (-ла) аватар кімнати"
"Ви змінили аватар кімнати"
"%1$s видалив (-ла) аватар кімнати"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index a0a17b3465..158eeec20d 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
@@ -78,7 +79,8 @@ class DefaultRoomLastMessageFormatterTest {
sp = AndroidStringProvider(context.resources),
roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider),
profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider),
- stateContentFormatter = StateContentFormatter(stringProvider)
+ stateContentFormatter = StateContentFormatter(stringProvider),
+ permalinkParser = FakePermalinkParser(),
)
}
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index a242d014d1..765e97f851 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -82,11 +82,11 @@ enum class FeatureFlags(
defaultValue = true,
isFinished = false,
),
- RoomModeration(
- key = "feature.roomModeration",
- title = "Room moderation",
- description = "Add moderation features to the room for users with permissions",
- defaultValue = true,
+ RoomDirectorySearch(
+ key = "feature.roomdirectorysearch",
+ title = "Room directory search",
+ description = "Allow user to search for public rooms in their homeserver",
+ defaultValue = false,
isFinished = false,
- ),
+ )
}
diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
index 4e7032a313..43fc0f0823 100644
--- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
+++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt
@@ -41,7 +41,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.Mentions -> true
FeatureFlags.MarkAsUnread -> true
FeatureFlags.RoomListFilters -> true
- FeatureFlags.RoomModeration -> false
+ FeatureFlags.RoomDirectorySearch -> false
}
} else {
false
diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts
index 9304634be2..6219296732 100644
--- a/libraries/matrix/api/build.gradle.kts
+++ b/libraries/matrix/api/build.gradle.kts
@@ -34,7 +34,6 @@ anvil {
}
dependencies {
- implementation(projects.appconfig)
implementation(projects.libraries.di)
implementation(libs.dagger)
implementation(projects.libraries.androidutils)
@@ -45,7 +44,5 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
- testImplementation(libs.test.robolectric)
- testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.matrix.test)
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index 955c8d72fa..fa5f083722 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
@@ -58,12 +59,14 @@ interface MatrixClient : Closeable {
suspend fun setDisplayName(displayName: String): Result
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result
suspend fun removeAvatar(): Result
+ suspend fun joinRoom(roomId: RoomId): Result
fun syncService(): SyncService
fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService
fun notificationService(): NotificationService
fun notificationSettingsService(): NotificationSettingsService
fun encryptionService(): EncryptionService
+ fun roomDirectoryService(): RoomDirectoryService
suspend fun getCacheSize(): Long
/**
@@ -88,4 +91,7 @@ interface MatrixClient : Closeable {
fun roomMembershipObserver(): RoomMembershipObserver
fun isMe(userId: UserId?) = userId == sessionId
+
+ suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result
+ suspend fun getRecentlyVisitedRooms(): Result>
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt
index 4ae7b7dc94..c525bd4c00 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt
@@ -17,40 +17,17 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
-import io.element.android.appconfig.MatrixConfiguration
/**
* Mapping of an input URI to a matrix.to compliant URI.
*/
-object MatrixToConverter {
+interface MatrixToConverter {
/**
* Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
- * To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
* Examples:
* - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
*/
- fun convert(uri: Uri): Uri? {
- val uriString = uri.toString()
- val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL
-
- return when {
- // URL is already a matrix.to
- uriString.startsWith(baseUrl) -> uri
- // Web or client url
- SUPPORTED_PATHS.any { it in uriString } -> {
- val path = SUPPORTED_PATHS.first { it in uriString }
- Uri.parse(baseUrl + uriString.substringAfter(path))
- }
- // URL is not supported
- else -> null
- }
- }
-
- private val SUPPORTED_PATHS = listOf(
- "/#/room/",
- "/#/user/",
- "/#/group/"
- )
+ fun convert(uri: Uri): Uri?
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
index c46e15db3b..14c29f2de5 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt
@@ -16,70 +16,13 @@
package io.element.android.libraries.matrix.api.permalink
-import io.element.android.appconfig.MatrixConfiguration
-import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
-object PermalinkBuilder {
- private const val ROOM_PATH = "room/"
- private const val USER_PATH = "user/"
-
- private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL).also {
- var baseUrl = it
- if (!baseUrl.endsWith("/")) {
- baseUrl += "/"
- }
- if (!baseUrl.endsWith("/#/")) {
- baseUrl += "/#/"
- }
- }
-
- fun permalinkForUser(userId: UserId): Result {
- return if (MatrixPatterns.isUserId(userId.value)) {
- val url = buildString {
- append(permalinkBaseUrl)
- if (!isMatrixTo()) {
- append(USER_PATH)
- }
- append(userId.value)
- }
- Result.success(url)
- } else {
- Result.failure(PermalinkBuilderError.InvalidUserId)
- }
- }
-
- fun permalinkForRoomAlias(roomAlias: String): Result {
- return if (MatrixPatterns.isRoomAlias(roomAlias)) {
- Result.success(permalinkForRoomAliasOrId(roomAlias))
- } else {
- Result.failure(PermalinkBuilderError.InvalidRoomAlias)
- }
- }
-
- fun permalinkForRoomId(roomId: RoomId): Result {
- return if (MatrixPatterns.isRoomId(roomId.value)) {
- Result.success(permalinkForRoomAliasOrId(roomId.value))
- } else {
- Result.failure(PermalinkBuilderError.InvalidRoomId)
- }
- }
-
- private fun permalinkForRoomAliasOrId(value: String): String {
- val id = escapeId(value)
- return buildString {
- append(permalinkBaseUrl)
- if (!isMatrixTo()) {
- append(ROOM_PATH)
- }
- append(id)
- }
- }
-
- private fun escapeId(value: String) = value.replace("/", "%2F")
-
- private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL)
+interface PermalinkBuilder {
+ fun permalinkForUser(userId: UserId): Result
+ fun permalinkForRoomAlias(roomAlias: String): Result
+ fun permalinkForRoomId(roomId: RoomId): Result
}
sealed class PermalinkBuilderError : Throwable() {
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
index 3b90aee1be..463f8fb32d 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
@@ -17,11 +17,6 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
-import android.net.UrlQuerySanitizer
-import io.element.android.libraries.matrix.api.core.MatrixPatterns
-import kotlinx.collections.immutable.toImmutableList
-import timber.log.Timber
-import java.net.URLDecoder
/**
* This class turns a uri to a [PermalinkData].
@@ -29,121 +24,15 @@ import java.net.URLDecoder
* or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
* or client permalinks (e.g. user/@chagai95:matrix.org)
*/
-object PermalinkParser {
+interface PermalinkParser {
/**
* Turns a uri string to a [PermalinkData].
*/
- fun parse(uriString: String): PermalinkData {
- val uri = Uri.parse(uriString)
- return parse(uri)
- }
+ fun parse(uriString: String): PermalinkData
/**
* Turns a uri to a [PermalinkData].
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
*/
- fun parse(uri: Uri): PermalinkData {
- // the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the
- // mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid
- // so convert URI to matrix.to to simplify parsing process
- val matrixToUri = MatrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
-
- // We can't use uri.fragment as it is decoding to early and it will break the parsing
- // of parameters that represents url (like signurl)
- val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment
- if (fragment.isEmpty()) {
- return PermalinkData.FallbackLink(uri)
- }
- val safeFragment = fragment.substringBefore('?')
- val viaQueryParameters = fragment.getViaParameters()
-
- // we are limiting to 2 params
- val params = safeFragment
- .split(MatrixPatterns.SEP_REGEX)
- .filter { it.isNotEmpty() }
- .take(2)
-
- val decodedParams = params
- .map { URLDecoder.decode(it, "UTF-8") }
-
- val identifier = params.getOrNull(0)
- val decodedIdentifier = decodedParams.getOrNull(0)
- val extraParameter = decodedParams.getOrNull(1)
- return when {
- identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri)
- MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier)
- MatrixPatterns.isRoomId(decodedIdentifier) -> {
- handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters)
- }
- MatrixPatterns.isRoomAlias(decodedIdentifier) -> {
- PermalinkData.RoomLink(
- roomIdOrAlias = decodedIdentifier,
- isRoomAlias = true,
- eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
- viaParameters = viaQueryParameters.toImmutableList()
- )
- }
- else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier))
- }
- }
-
- private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List): PermalinkData {
- // Can't rely on built in parsing because it's messing around the signurl
- val paramList = safeExtractParams(fragment)
- val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second
- val email = paramList.firstOrNull { it.first == "email" }?.second
- return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) {
- try {
- val signValidUri = Uri.parse(signUrl)
- val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException("missing `authority`")
- val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException("missing `token`")
- val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException("missing `private_key`")
- PermalinkData.RoomEmailInviteLink(
- roomId = identifier,
- email = email!!,
- signUrl = signUrl!!,
- roomName = paramList.firstOrNull { it.first == "room_name" }?.second,
- inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second,
- roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second,
- roomType = paramList.firstOrNull { it.first == "room_type" }?.second,
- identityServer = identityServerHost,
- token = token,
- privateKey = privateKey
- )
- } catch (failure: Throwable) {
- Timber.i("## Permalink: Failed to parse permalink $signUrl")
- PermalinkData.FallbackLink(uri)
- }
- } else {
- PermalinkData.RoomLink(
- roomIdOrAlias = identifier,
- isRoomAlias = false,
- eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
- viaParameters = viaQueryParameters.toImmutableList()
- )
- }
- }
-
- private fun safeExtractParams(fragment: String) =
- fragment.substringAfter("?").split('&').mapNotNull {
- val splitNameValue = it.split("=")
- if (splitNameValue.size == 2) {
- Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8"))
- } else {
- null
- }
- }
-
- private fun String.getViaParameters(): List {
- return runCatching {
- UrlQuerySanitizer(this)
- .parameterList
- .filter {
- it.mParameter == "via"
- }
- .map {
- URLDecoder.decode(it.mValue, "UTF-8")
- }
- }.getOrDefault(emptyList())
- }
+ fun parse(uri: Uri): PermalinkData
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index 7e407aad4f..07b5b310bc 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -35,13 +35,8 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.map
import java.io.Closeable
import java.io.File
@@ -55,6 +50,7 @@ interface MatrixRoom : Closeable {
val topic: String?
val avatarUrl: String?
val isEncrypted: Boolean
+ val isSpace: Boolean
val isDirect: Boolean
val isPublic: Boolean
val activeMemberCount: Long
@@ -86,6 +82,12 @@ interface MatrixRoom : Closeable {
*/
suspend fun updateMembers()
+ /**
+ * Get the members of the room. Note: generally this should not be used, please use
+ * [membersStateFlow] and [updateMembers] instead.
+ */
+ suspend fun getMembers(limit: Int = 5): Result>
+
/**
* Will return an updated member or an error.
*/
@@ -182,18 +184,6 @@ interface MatrixRoom : Closeable {
suspend fun canUserJoinCall(userId: UserId): Result =
canUserSendState(userId, StateEventType.CALL_MEMBER)
- fun usersWithRole(role: RoomMember.Role): Flow> {
- return roomInfoFlow
- .map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
- .distinctUntilChanged()
- .combine(membersStateFlow) { powerLevels, membersState ->
- membersState.roomMembers()
- .orEmpty()
- .filter { powerLevels.containsKey(it.userId) }
- .toPersistentList()
- }
- }
-
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result
suspend fun removeAvatar(): Result
@@ -327,5 +317,12 @@ interface MatrixRoom : Closeable {
*/
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result
+ /**
+ * Get the permalink for the provided [eventId].
+ * @param eventId The event id to get the permalink for.
+ * @return The permalink, or a failure.
+ */
+ suspend fun getPermalinkFor(eventId: EventId): Result
+
override fun close() = destroy()
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt
index 759b0f46cb..13f19fe0e0 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt
@@ -35,3 +35,7 @@ fun MatrixRoomMembersState.roomMembers(): List? {
else -> null
}
}
+
+fun MatrixRoomMembersState.joinedRoomMembers(): List {
+ return roomMembers().orEmpty().filter { it.membership == RoomMembershipState.JOIN }
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt
index f6609a2bce..4415e51327 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt
@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.user.MatrixUser
data class RoomMember(
val userId: UserId,
@@ -78,3 +79,9 @@ enum class RoomMembershipState {
fun RoomMember.getBestName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
}
+
+fun RoomMember.toMatrixUser() = MatrixUser(
+ userId = userId,
+ displayName = displayName,
+ avatarUrl = avatarUrl,
+)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt
new file mode 100644
index 0000000000..2c17718ccf
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room.powerlevels
+
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.joinedRoomMembers
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+/**
+ * Return a flow of the list of room members who are still in the room (with membership == RoomMembershipState.JOIN)
+ * and who have the given role.
+ */
+fun MatrixRoom.usersWithRole(role: RoomMember.Role): Flow> {
+ return roomInfoFlow
+ .map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
+ .combine(membersStateFlow) { powerLevels, membersState ->
+ membersState.joinedRoomMembers()
+ .filter { powerLevels.containsKey(it.userId) }
+ .toPersistentList()
+ }
+ .distinctUntilChanged()
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt
index f2577fcb6b..ef4e6f747e 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt
@@ -42,7 +42,7 @@ suspend fun MatrixRoom.canInvite(): Result = canUserInvite(sessionId)
suspend fun MatrixRoom.canKick(): Result = canUserKick(sessionId)
/**
- * Shortcut for calling [MatrixRoom.canBanUser] with our own user.
+ * Shortcut for calling [MatrixRoom.canUserBan] with our own user.
*/
suspend fun MatrixRoom.canBan(): Result = canUserBan(sessionId)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt
new file mode 100644
index 0000000000..c2fb147aa0
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.room.recent
+
+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.UserId
+import io.element.android.libraries.matrix.api.room.CurrentUserMembership
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.toMatrixUser
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import kotlinx.coroutines.flow.first
+
+private const val MAX_RECENT_DIRECT_ROOMS_TO_RETURN = 5
+
+data class RecentDirectRoom(
+ val roomId: RoomId,
+ val matrixUser: MatrixUser,
+)
+
+suspend fun MatrixClient.getRecentDirectRooms(
+ maxNumberOfResults: Int = MAX_RECENT_DIRECT_ROOMS_TO_RETURN,
+): List {
+ val result = mutableListOf()
+ val foundUserIds = mutableSetOf()
+ getRecentlyVisitedRooms().getOrNull()?.let { roomIds ->
+ roomIds
+ .mapNotNull { roomId -> getRoom(roomId) }
+ .filter { it.isDm && it.isJoined() }
+ .map { room ->
+ val otherUser = room.getMembers().getOrNull()
+ ?.firstOrNull { it.userId != sessionId }
+ ?.takeIf { foundUserIds.add(it.userId) }
+ ?.toMatrixUser()
+ if (otherUser != null) {
+ result.add(
+ RecentDirectRoom(room.roomId, otherUser)
+ )
+ // Return early to avoid useless computation
+ if (result.size >= maxNumberOfResults) {
+ return@map
+ }
+ }
+ }
+ }
+ return result
+}
+
+suspend fun MatrixRoom.isJoined(): Boolean {
+ return roomInfoFlow.first().currentUserMembership == CurrentUserMembership.JOINED
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt
new file mode 100644
index 0000000000..78d6cb0c94
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.roomdirectory
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+data class RoomDescription(
+ val roomId: RoomId,
+ val name: String?,
+ val topic: String?,
+ val alias: String?,
+ val avatarUrl: String?,
+ val joinRule: JoinRule,
+ val isWorldReadable: Boolean,
+ val numberOfMembers: Long
+) {
+ enum class JoinRule {
+ PUBLIC,
+ KNOCK,
+ UNKNOWN
+ }
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt
new file mode 100644
index 0000000000..2311c5afee
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.roomdirectory
+
+import kotlinx.coroutines.flow.Flow
+
+interface RoomDirectoryList {
+ suspend fun filter(filter: String?, batchSize: Int): Result
+ suspend fun loadMore(): Result
+ val state: Flow
+
+ data class State(
+ val hasMoreToLoad: Boolean,
+ val items: List,
+ )
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt
new file mode 100644
index 0000000000..26df48be71
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.api.roomdirectory
+
+import kotlinx.coroutines.CoroutineScope
+
+interface RoomDirectoryService {
+ fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt
index 00fd9dc561..b82dd23188 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt
@@ -21,6 +21,17 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface SessionVerificationService {
+ /**
+ * This flow stores the local verification status of the current session.
+ *
+ * We should ideally base the verified status in the Rust SDK info, but there are several issues with that approach:
+ *
+ * - The SDK takes a while to report this value, resulting in a delay of 1-2s in displaying the UI.
+ * - We need to add a 'Skip' option for testing purposes, which would not be possible if we relied only on the SDK.
+ * - The SDK sometimes doesn't report the verification state if there is no network connection when the app boots.
+ */
+ val needsVerificationFlow: StateFlow
+
/**
* State of the current verification flow ([VerificationFlowState.Initial] if not started).
*/
@@ -72,6 +83,11 @@ interface SessionVerificationService {
* Returns the verification service state to the initial step.
*/
suspend fun reset()
+
+ /**
+ * Saves the current session state as [verified].
+ */
+ suspend fun saveVerifiedState(verified: Boolean)
}
/** Verification status of the current session. */
@@ -85,6 +101,9 @@ sealed interface SessionVerifiedStatus {
/** Verified session status. */
data object Verified : SessionVerifiedStatus
+
+ /** Returns whether the session is [Verified]. */
+ fun isVerified(): Boolean = this is Verified
}
/** States produced by the [SessionVerificationService]. */
diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts
index d50bad6696..523ea2fda1 100644
--- a/libraries/matrix/impl/build.gradle.kts
+++ b/libraries/matrix/impl/build.gradle.kts
@@ -36,6 +36,7 @@ dependencies {
} else {
debugImplementation(libs.matrix.sdk)
}
+ implementation(projects.appconfig)
implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.network)
@@ -52,8 +53,10 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
+ testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
+ testImplementation(projects.tests.testutils)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.turbine)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 31e439bbec..f67548afae 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl
+import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.androidutils.file.getSizeOfFiles
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -35,9 +36,11 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.api.sync.SyncService
+import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -53,6 +56,8 @@ import io.element.android.libraries.matrix.impl.room.MatrixRoomInfoMapper
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
+import io.element.android.libraries.matrix.impl.room.map
+import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
@@ -71,6 +76,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -79,8 +85,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -94,13 +99,14 @@ import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
-import org.matrix.rustcomponents.sdk.StateEventType
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility
@@ -124,11 +130,6 @@ class RustMatrixClient(
private val innerRoomListService = syncService.roomListService()
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope)
- private val verificationService = RustSessionVerificationService(
- client = client,
- syncService = rustSyncService,
- sessionCoroutineScope = sessionCoroutineScope,
- ).apply { start() }
private val pushersService = RustPushersService(
client = client,
dispatchers = dispatchers,
@@ -149,7 +150,14 @@ class RustMatrixClient(
syncService = rustSyncService,
sessionCoroutineScope = sessionCoroutineScope,
dispatchers = dispatchers,
+ sessionStore = sessionStore,
)
+
+ private val roomDirectoryService = RustRoomDirectoryService(
+ client = client,
+ sessionDispatcher = sessionDispatcher,
+ )
+
private val sessionDirectoryNameProvider = SessionDirectoryNameProvider()
private val isLoggingOut = AtomicBoolean(false)
@@ -170,6 +178,7 @@ class RustMatrixClient(
isTokenValid = false,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
+ needsVerification = existingData.needsVerification,
)
sessionStore.updateData(newData)
Timber.d("Removed session data with token: '...$anonymizedToken'.")
@@ -197,6 +206,7 @@ class RustMatrixClient(
isTokenValid = true,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
+ needsVerification = existingData.needsVerification,
)
sessionStore.updateData(newData)
Timber.d("Saved new session data with token: '...$anonymizedToken'.")
@@ -208,42 +218,38 @@ class RustMatrixClient(
}
}
- private val rustRoomListService: RoomListService =
- RustRoomListService(
+ override val roomListService: RoomListService = RustRoomListService(
+ innerRoomListService = innerRoomListService,
+ sessionCoroutineScope = sessionCoroutineScope,
+ sessionDispatcher = sessionDispatcher,
+ roomListFactory = RoomListFactory(
innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope,
- sessionDispatcher = sessionDispatcher,
- roomListFactory = RoomListFactory(
- innerRoomListService = innerRoomListService,
- sessionCoroutineScope = sessionCoroutineScope,
- ),
- )
-
- private val eventFilters = TimelineEventTypeFilter.exclude(
- listOf(
- StateEventType.ROOM_ALIASES,
- StateEventType.ROOM_CANONICAL_ALIAS,
- StateEventType.ROOM_GUEST_ACCESS,
- StateEventType.ROOM_HISTORY_VISIBILITY,
- StateEventType.ROOM_JOIN_RULES,
- StateEventType.ROOM_PINNED_EVENTS,
- StateEventType.ROOM_POWER_LEVELS,
- StateEventType.ROOM_SERVER_ACL,
- StateEventType.ROOM_TOMBSTONE,
- StateEventType.SPACE_CHILD,
- StateEventType.SPACE_PARENT,
- StateEventType.POLICY_RULE_ROOM,
- StateEventType.POLICY_RULE_SERVER,
- StateEventType.POLICY_RULE_USER,
- ).map(FilterTimelineEventType::State)
+ ),
)
- override val roomListService: RoomListService
- get() = rustRoomListService
+ private val verificationService = RustSessionVerificationService(
+ client = client,
+ isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
+ sessionCoroutineScope = sessionCoroutineScope,
+ sessionStore = sessionStore,
+ )
- private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client)
- override val mediaLoader: MatrixMediaLoader
- get() = rustMediaLoader
+ private val eventFilters = TimelineConfig.excludedEvents
+ .takeIf { it.isNotEmpty() }
+ ?.let { listStateEventType ->
+ TimelineEventTypeFilter.exclude(
+ listStateEventType.map { stateEventType ->
+ FilterTimelineEventType.State(stateEventType.map())
+ }
+ )
+ }
+
+ override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
+ baseCacheDirectory = baseCacheDirectory,
+ dispatchers = dispatchers,
+ innerClient = client,
+ )
private val roomMembershipObserver = RoomMembershipObserver()
@@ -273,11 +279,6 @@ class RustMatrixClient(
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
init {
- roomListService.state.onEach { state ->
- if (state == RoomListService.State.Running) {
- setupVerificationControllerIfNeeded()
- }
- }.launchIn(sessionCoroutineScope)
sessionCoroutineScope.launch {
// Force a refresh of the profile
getUserProfile()
@@ -311,6 +312,22 @@ class RustMatrixClient(
}
}
+ /**
+ * Wait for the room to be available in the room list.
+ * @param roomId the room id to wait for
+ * @param timeout the timeout to wait for the room to be available
+ * @throws TimeoutCancellationException if the room is not available after the timeout
+ */
+ private suspend fun awaitRoom(roomId: RoomId, timeout: Duration) {
+ withTimeout(timeout) {
+ roomListService.allRooms.summaries
+ .filter { roomSummaries ->
+ roomSummaries.map { it.identifier() }.contains(roomId.value)
+ }
+ .first()
+ }
+ }
+
private suspend fun pairOfRoom(roomId: RoomId): Pair? {
val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value)
val fullRoom = cachedRoomListItem?.fullRoomWithTimeline(filter = eventFilters)
@@ -360,14 +377,11 @@ class RustMatrixClient(
powerLevelContentOverride = defaultRoomCreationPowerLevels,
)
val roomId = RoomId(client.createRoom(rustParams))
-
- // Wait to receive the room back from the sync
- withTimeout(30_000L) {
- roomListService.allRooms.summaries
- .filter { roomSummaries ->
- roomSummaries.map { it.identifier() }.contains(roomId.value)
- }
- .first()
+ // Wait to receive the room back from the sync but do not returns failure if it fails.
+ try {
+ awaitRoom(roomId, 30.seconds)
+ } catch (e: Exception) {
+ Timber.e(e, "Timeout waiting for the room to be available in the room list")
}
roomId
}
@@ -416,6 +430,30 @@ class RustMatrixClient(
runCatching { client.removeAvatar() }
}
+ override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) {
+ runCatching {
+ client.joinRoomById(roomId.value).destroy()
+ try {
+ awaitRoom(roomId, 10.seconds)
+ } catch (e: Exception) {
+ Timber.e(e, "Timeout waiting for the room to be available in the room list")
+ }
+ roomId
+ }
+ }
+
+ override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result = withContext(sessionDispatcher) {
+ runCatching {
+ client.trackRecentlyVisitedRoom(roomId.value)
+ }
+ }
+
+ override suspend fun getRecentlyVisitedRooms(): Result> = withContext(sessionDispatcher) {
+ runCatching {
+ client.getRecentlyVisitedRooms().map(::RoomId)
+ }
+ }
+
override fun syncService(): SyncService = rustSyncService
override fun sessionVerificationService(): SessionVerificationService = verificationService
@@ -428,6 +466,8 @@ class RustMatrixClient(
override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
+ override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService
+
override fun close() {
sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -462,6 +502,7 @@ class RustMatrixClient(
ignoreSdkError: Boolean,
): String? {
var result: String? = null
+ syncService.stop()
withContext(sessionDispatcher) {
if (doRequest) {
try {
@@ -497,16 +538,6 @@ class RustMatrixClient(
}
}
- private fun setupVerificationControllerIfNeeded() {
- if (verificationService.verificationController == null) {
- try {
- verificationService.verificationController = client.getSessionVerificationController()
- } catch (e: Throwable) {
- Timber.e(e, "Could not start verification service. Will try again on the next sliding sync update.")
- }
- }
- }
-
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
private suspend fun File.getCacheSize(
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index 29dd327ae2..ad34e30d18 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -148,6 +148,7 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase,
+ needsVerification = true,
)
}
sessionStore.storeData(sessionData)
@@ -195,7 +196,8 @@ class RustMatrixAuthenticationService @Inject constructor(
it.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,
- passphrase = pendingPassphrase
+ passphrase = pendingPassphrase,
+ needsVerification = true,
)
}
pendingOidcAuthenticationData?.close()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
index 17ea8ee444..bf5c4c601f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
@@ -68,4 +69,9 @@ object SessionMatrixModule {
fun provideSessionCoroutineScope(matrixClient: MatrixClient): CoroutineScope {
return matrixClient.sessionCoroutineScope
}
+
+ @Provides
+ fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService {
+ return matrixClient.roomDirectoryService()
+ }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
index f5a6390989..25881c2174 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
@@ -25,6 +25,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.sync.SyncState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
+import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.currentCoroutineContext
@@ -48,10 +49,11 @@ import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecover
import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException
internal class RustEncryptionService(
- client: Client,
+ private val client: Client,
syncService: RustSyncService,
sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
+ private val sessionStore: SessionStore,
) : EncryptionService {
private val service: Encryption = client.encryption()
@@ -186,6 +188,9 @@ internal class RustEncryptionService(
override suspend fun recover(recoveryKey: String): Result = withContext(dispatchers.io) {
runCatching {
service.recover(recoveryKey)
+ val existingSession = sessionStore.getSession(client.userId())
+ ?: error("Failed to save verification state. No session with id ${client.userId()}")
+ sessionStore.updateData(existingSession.copy(needsVerification = false))
}.mapFailure {
it.mapRecoveryException()
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
index aea838b705..3c1e3c40ec 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
@@ -25,6 +25,7 @@ internal fun Session.toSessionData(
isTokenValid: Boolean,
loginType: LoginType,
passphrase: String?,
+ needsVerification: Boolean,
) = SessionData(
userId = userId,
deviceId = deviceId,
@@ -37,4 +38,5 @@ internal fun Session.toSessionData(
isTokenValid = isTokenValid,
loginType = loginType,
passphrase = passphrase,
+ needsVerification = needsVerification,
)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt
new file mode 100644
index 0000000000..a5271c0b22
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.permalink
+
+import android.net.Uri
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.MatrixConfiguration
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.permalink.MatrixToConverter
+import javax.inject.Inject
+
+/**
+ * Mapping of an input URI to a matrix.to compliant URI.
+ */
+@ContributesBinding(AppScope::class)
+class DefaultMatrixToConverter @Inject constructor() : MatrixToConverter {
+ /**
+ * Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
+ * To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
+ * Examples:
+ * - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
+ * - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
+ * - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
+ */
+ override fun convert(uri: Uri): Uri? {
+ val uriString = uri.toString()
+ val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL
+
+ return when {
+ // URL is already a matrix.to
+ uriString.startsWith(baseUrl) -> uri
+ // Web or client url
+ SUPPORTED_PATHS.any { it in uriString } -> {
+ val path = SUPPORTED_PATHS.first { it in uriString }
+ Uri.parse(baseUrl + uriString.substringAfter(path))
+ }
+ // URL is not supported
+ else -> null
+ }
+ }
+
+ companion object {
+ val SUPPORTED_PATHS = listOf(
+ "/#/room/",
+ "/#/user/",
+ "/#/group/"
+ )
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
new file mode 100644
index 0000000000..ae30c94c9c
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.permalink
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.MatrixConfiguration
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultPermalinkBuilder @Inject constructor() : PermalinkBuilder {
+ private val permalinkBaseUrl
+ get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL).also {
+ var baseUrl = it
+ if (!baseUrl.endsWith("/")) {
+ baseUrl += "/"
+ }
+ if (!baseUrl.endsWith("/#/")) {
+ baseUrl += "/#/"
+ }
+ }
+
+ override fun permalinkForUser(userId: UserId): Result {
+ return if (MatrixPatterns.isUserId(userId.value)) {
+ val url = buildString {
+ append(permalinkBaseUrl)
+ if (!isMatrixTo()) {
+ append(USER_PATH)
+ }
+ append(userId.value)
+ }
+ Result.success(url)
+ } else {
+ Result.failure(PermalinkBuilderError.InvalidUserId)
+ }
+ }
+
+ override fun permalinkForRoomAlias(roomAlias: String): Result {
+ return if (MatrixPatterns.isRoomAlias(roomAlias)) {
+ Result.success(permalinkForRoomAliasOrId(roomAlias))
+ } else {
+ Result.failure(PermalinkBuilderError.InvalidRoomAlias)
+ }
+ }
+
+ override fun permalinkForRoomId(roomId: RoomId): Result {
+ return if (MatrixPatterns.isRoomId(roomId.value)) {
+ Result.success(permalinkForRoomAliasOrId(roomId.value))
+ } else {
+ Result.failure(PermalinkBuilderError.InvalidRoomId)
+ }
+ }
+
+ private fun permalinkForRoomAliasOrId(value: String): String {
+ val id = escapeId(value)
+ return buildString {
+ append(permalinkBaseUrl)
+ if (!isMatrixTo()) {
+ append(ROOM_PATH)
+ }
+ append(id)
+ }
+ }
+
+ private fun escapeId(value: String) = value.replace("/", "%2F")
+
+ private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL)
+
+ companion object {
+ private const val ROOM_PATH = "room/"
+ private const val USER_PATH = "user/"
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt
new file mode 100644
index 0000000000..ab91a89af9
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.permalink
+
+import android.net.Uri
+import android.net.UrlQuerySanitizer
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.permalink.MatrixToConverter
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+import kotlinx.collections.immutable.toImmutableList
+import timber.log.Timber
+import java.net.URLDecoder
+import javax.inject.Inject
+
+/**
+ * This class turns a uri to a [PermalinkData].
+ * element-based domains (e.g. https://app.element.io/#/user/@chagai95:matrix.org) permalinks
+ * or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
+ * or client permalinks (e.g. user/@chagai95:matrix.org)
+ */
+@ContributesBinding(AppScope::class)
+class DefaultPermalinkParser @Inject constructor(
+ private val matrixToConverter: MatrixToConverter
+) : PermalinkParser {
+ /**
+ * Turns a uri string to a [PermalinkData].
+ */
+ override fun parse(uriString: String): PermalinkData {
+ val uri = Uri.parse(uriString)
+ return parse(uri)
+ }
+
+ /**
+ * Turns a uri to a [PermalinkData].
+ * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
+ */
+ override fun parse(uri: Uri): PermalinkData {
+ // the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the
+ // mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid
+ // so convert URI to matrix.to to simplify parsing process
+ val matrixToUri = matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
+
+ // We can't use uri.fragment as it is decoding to early and it will break the parsing
+ // of parameters that represents url (like signurl)
+ val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment
+ if (fragment.isEmpty()) {
+ return PermalinkData.FallbackLink(uri)
+ }
+ val safeFragment = fragment.substringBefore('?')
+ val viaQueryParameters = fragment.getViaParameters()
+
+ // we are limiting to 2 params
+ val params = safeFragment
+ .split(MatrixPatterns.SEP_REGEX)
+ .filter { it.isNotEmpty() }
+ .take(2)
+
+ val decodedParams = params
+ .map { URLDecoder.decode(it, "UTF-8") }
+
+ val identifier = params.getOrNull(0)
+ val decodedIdentifier = decodedParams.getOrNull(0)
+ val extraParameter = decodedParams.getOrNull(1)
+ return when {
+ identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri)
+ MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier)
+ MatrixPatterns.isRoomId(decodedIdentifier) -> {
+ handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters)
+ }
+ MatrixPatterns.isRoomAlias(decodedIdentifier) -> {
+ PermalinkData.RoomLink(
+ roomIdOrAlias = decodedIdentifier,
+ isRoomAlias = true,
+ eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
+ viaParameters = viaQueryParameters.toImmutableList()
+ )
+ }
+ else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier))
+ }
+ }
+
+ private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List): PermalinkData {
+ // Can't rely on built in parsing because it's messing around the signurl
+ val paramList = safeExtractParams(fragment)
+ val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second
+ val email = paramList.firstOrNull { it.first == "email" }?.second
+ return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) {
+ try {
+ val signValidUri = Uri.parse(signUrl)
+ val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException("missing `authority`")
+ val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException("missing `token`")
+ val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException("missing `private_key`")
+ PermalinkData.RoomEmailInviteLink(
+ roomId = identifier,
+ email = email!!,
+ signUrl = signUrl!!,
+ roomName = paramList.firstOrNull { it.first == "room_name" }?.second,
+ inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second,
+ roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second,
+ roomType = paramList.firstOrNull { it.first == "room_type" }?.second,
+ identityServer = identityServerHost,
+ token = token,
+ privateKey = privateKey
+ )
+ } catch (failure: Throwable) {
+ Timber.i("## Permalink: Failed to parse permalink $signUrl")
+ PermalinkData.FallbackLink(uri)
+ }
+ } else {
+ PermalinkData.RoomLink(
+ roomIdOrAlias = identifier,
+ isRoomAlias = false,
+ eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
+ viaParameters = viaQueryParameters.toImmutableList()
+ )
+ }
+ }
+
+ private fun safeExtractParams(fragment: String) =
+ fragment.substringAfter("?").split('&').mapNotNull {
+ val splitNameValue = it.split("=")
+ if (splitNameValue.size == 2) {
+ Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8"))
+ } else {
+ null
+ }
+ }
+
+ private fun String.getViaParameters(): List {
+ return runCatching {
+ UrlQuerySanitizer(this)
+ .parameterList
+ .filter {
+ it.mParameter == "via"
+ }
+ .map {
+ URLDecoder.decode(it.mValue, "UTF-8")
+ }
+ }.getOrDefault(emptyList())
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 622d39bd61..0531c59d4a 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.room
+import io.element.android.appconfig.MatrixConfiguration
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.matrix.api.core.EventId
@@ -70,8 +71,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EventTimelineItem
@@ -163,7 +169,11 @@ class RustMatrixRoom(
override val syncUpdateFlow: StateFlow = _syncUpdateFlow.asStateFlow()
init {
- timeline.membershipChangeEventReceived
+ val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
+ val membershipChanges = timeline.membershipChangeEventReceived.onStart { emit(Unit) }
+ combine(membershipChanges, powerLevelChanges) { _, _ -> }
+ // Skip initial one
+ .drop(1)
// The new events should already be in the SDK cache, no need to fetch them from the server
.onEach { roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) }
.launchIn(roomCoroutineScope)
@@ -182,45 +192,40 @@ class RustMatrixRoom(
}
override val name: String?
- get() {
- return roomListItem.name()
- }
+ get() = runCatching { roomListItem.name() }.getOrDefault(null)
override val displayName: String
- get() {
- return innerRoom.displayName()
- }
+ get() = runCatching { innerRoom.displayName() }.getOrDefault("")
override val topic: String?
- get() {
- return innerRoom.topic()
- }
+ get() = runCatching { innerRoom.topic() }.getOrDefault(null)
override val avatarUrl: String?
- get() {
- return roomListItem.avatarUrl() ?: innerRoom.avatarUrl()
- }
+ get() = runCatching { roomListItem.avatarUrl() ?: innerRoom.avatarUrl() }.getOrDefault(null)
override val isEncrypted: Boolean
get() = runCatching { innerRoom.isEncrypted() }.getOrDefault(false)
override val alias: String?
- get() = innerRoom.canonicalAlias()
+ get() = runCatching { innerRoom.canonicalAlias() }.getOrDefault(null)
override val alternativeAliases: List
- get() = innerRoom.alternativeAliases()
+ get() = runCatching { innerRoom.alternativeAliases() }.getOrDefault(emptyList())
override val isPublic: Boolean
- get() = innerRoom.isPublic()
+ get() = runCatching { innerRoom.isPublic() }.getOrDefault(false)
+
+ override val isSpace: Boolean
+ get() = runCatching { innerRoom.isSpace() }.getOrDefault(false)
override val isDirect: Boolean
- get() = innerRoom.isDirect()
+ get() = runCatching { innerRoom.isDirect() }.getOrDefault(false)
override val joinedMemberCount: Long
- get() = innerRoom.joinedMembersCount().toLong()
+ get() = runCatching { innerRoom.joinedMembersCount().toLong() }.getOrDefault(0)
override val activeMemberCount: Long
- get() = innerRoom.activeMembersCount().toLong()
+ get() = runCatching { innerRoom.activeMembersCount().toLong() }.getOrDefault(0)
override suspend fun updateMembers() {
val useCache = membersStateFlow.value is MatrixRoomMembersState.Unknown
@@ -232,6 +237,16 @@ class RustMatrixRoom(
roomMemberListFetcher.fetchRoomMembers(source = source)
}
+ override suspend fun getMembers(limit: Int) = withContext(roomDispatcher) {
+ runCatching {
+ innerRoom.members().use {
+ it.nextChunk(limit.toUInt()).orEmpty().map { roomMember ->
+ RoomMemberMapper.map(roomMember)
+ }
+ }
+ }
+ }
+
override suspend fun getUpdatedMember(userId: UserId): Result = withContext(roomDispatcher) {
runCatching {
RoomMemberMapper.map(innerRoom.member(userId.value))
@@ -716,6 +731,19 @@ class RustMatrixRoom(
)
}
+ override suspend fun getPermalinkFor(eventId: EventId): Result {
+ // FIXME Use the SDK API once https://github.com/matrix-org/matrix-rust-sdk/issues/3259 has been done
+ // Now use a simple builder
+ return runCatching {
+ buildString {
+ append(MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL)
+ append(roomId.value)
+ append("/")
+ append(eventId.value)
+ }
+ }
+ }
+
private fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result {
return runCatching {
MediaUploadHandlerImpl(files, handle())
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt
new file mode 100644
index 0000000000..876a58d3a5
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import org.matrix.rustcomponents.sdk.PublicRoomJoinRule
+import org.matrix.rustcomponents.sdk.RoomDescription as RustRoomDescription
+
+class RoomDescriptionMapper {
+ fun map(roomDescription: RustRoomDescription): RoomDescription {
+ return RoomDescription(
+ roomId = RoomId(roomDescription.roomId),
+ name = roomDescription.name,
+ topic = roomDescription.topic,
+ avatarUrl = roomDescription.avatarUrl,
+ alias = roomDescription.alias,
+ joinRule = when (roomDescription.joinRule) {
+ PublicRoomJoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC
+ PublicRoomJoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK
+ null -> RoomDescription.JoinRule.UNKNOWN
+ },
+ isWorldReadable = roomDescription.isWorldReadable,
+ numberOfMembers = roomDescription.joinedMembers.toLong(),
+ )
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt
new file mode 100644
index 0000000000..28e82f4e40
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.catch
+import org.matrix.rustcomponents.sdk.RoomDirectorySearch
+import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntriesListener
+import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate
+import timber.log.Timber
+
+internal fun RoomDirectorySearch.resultsFlow(): Flow> =
+ callbackFlow {
+ val listener = object : RoomDirectorySearchEntriesListener {
+ override fun onUpdate(roomEntriesUpdate: List) {
+ trySendBlocking(roomEntriesUpdate)
+ }
+ }
+ val result = results(listener)
+ awaitClose {
+ result.cancelAndDestroy()
+ }
+ }.catch {
+ Timber.d(it, "timelineDiffFlow() failed")
+ }.buffer(Channel.UNLIMITED)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt
new file mode 100644
index 0000000000..aff631d6b4
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate
+import timber.log.Timber
+import kotlin.coroutines.CoroutineContext
+
+class RoomDirectorySearchProcessor(
+ private val roomDescriptions: MutableSharedFlow>,
+ private val coroutineContext: CoroutineContext,
+ private val roomDescriptionMapper: RoomDescriptionMapper,
+) {
+ private val mutex = Mutex()
+
+ suspend fun postUpdates(updates: List) {
+ updateRoomDescriptions {
+ Timber.v("Update room descriptions from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}")
+ updates.forEach { update ->
+ applyUpdate(update)
+ }
+ }
+ }
+
+ private fun MutableList.applyUpdate(update: RoomDirectorySearchEntryUpdate) {
+ when (update) {
+ is RoomDirectorySearchEntryUpdate.Append -> {
+ val roomSummaries = update.values.map(roomDescriptionMapper::map)
+ addAll(roomSummaries)
+ }
+ is RoomDirectorySearchEntryUpdate.PushBack -> {
+ val roomDescription = roomDescriptionMapper.map(update.value)
+ add(roomDescription)
+ }
+ is RoomDirectorySearchEntryUpdate.PushFront -> {
+ val roomDescription = roomDescriptionMapper.map(update.value)
+ add(0, roomDescription)
+ }
+ is RoomDirectorySearchEntryUpdate.Set -> {
+ val roomDescription = roomDescriptionMapper.map(update.value)
+ this[update.index.toInt()] = roomDescription
+ }
+ is RoomDirectorySearchEntryUpdate.Insert -> {
+ val roomDescription = roomDescriptionMapper.map(update.value)
+ add(update.index.toInt(), roomDescription)
+ }
+ is RoomDirectorySearchEntryUpdate.Remove -> {
+ removeAt(update.index.toInt())
+ }
+ is RoomDirectorySearchEntryUpdate.Reset -> {
+ clear()
+ addAll(update.values.map(roomDescriptionMapper::map))
+ }
+ RoomDirectorySearchEntryUpdate.PopBack -> {
+ removeLastOrNull()
+ }
+ RoomDirectorySearchEntryUpdate.PopFront -> {
+ removeFirstOrNull()
+ }
+ RoomDirectorySearchEntryUpdate.Clear -> {
+ clear()
+ }
+ is RoomDirectorySearchEntryUpdate.Truncate -> {
+ subList(update.length.toInt(), size).clear()
+ }
+ }
+ }
+
+ private suspend fun updateRoomDescriptions(block: suspend MutableList.() -> Unit) = withContext(coroutineContext) {
+ mutex.withLock {
+ val current = roomDescriptions.replayCache.lastOrNull()
+ val mutableRoomSummaries = current.orEmpty().toMutableList()
+ block(mutableRoomSummaries)
+ roomDescriptions.emit(mutableRoomSummaries)
+ }
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt
new file mode 100644
index 0000000000..9b444d5ce5
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.matrix.rustcomponents.sdk.RoomDirectorySearch
+import kotlin.coroutines.CoroutineContext
+
+class RustRoomDirectoryList(
+ private val inner: RoomDirectorySearch,
+ coroutineScope: CoroutineScope,
+ private val coroutineContext: CoroutineContext,
+) : RoomDirectoryList {
+ private val hasMoreToLoad = MutableStateFlow(true)
+ private val items = MutableSharedFlow>(replay = 1)
+ private val processor = RoomDirectorySearchProcessor(items, coroutineContext, RoomDescriptionMapper())
+
+ init {
+ launchIn(coroutineScope)
+ }
+
+ private fun launchIn(coroutineScope: CoroutineScope) {
+ inner
+ .resultsFlow()
+ .onEach { updates ->
+ processor.postUpdates(updates)
+ }
+ .flowOn(coroutineContext)
+ .launchIn(coroutineScope)
+ }
+
+ override suspend fun filter(filter: String?, batchSize: Int): Result {
+ return execute {
+ inner.search(filter = filter, batchSize = batchSize.toUInt())
+ }
+ }
+
+ override suspend fun loadMore(): Result {
+ return execute {
+ inner.nextPage()
+ }
+ }
+
+ private suspend fun execute(action: suspend () -> Unit): Result {
+ return try {
+ // We always assume there is more to load until we know there isn't.
+ // As accessing hasMoreToLoad is otherwise blocked by the current action.
+ hasMoreToLoad.value = true
+ action()
+ Result.success(Unit)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Result.failure(e)
+ } finally {
+ hasMoreToLoad.value = hasMoreToLoad()
+ }
+ }
+
+ private suspend fun hasMoreToLoad(): Boolean {
+ return !inner.isAtLastPage()
+ }
+
+ override val state: Flow =
+ combine(hasMoreToLoad, items) { hasMoreToLoad, items ->
+ RoomDirectoryList.State(
+ hasMoreToLoad = hasMoreToLoad,
+ items = items
+ )
+ }
+ .flowOn(coroutineContext)
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt
new file mode 100644
index 0000000000..2939001b21
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.impl.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import org.matrix.rustcomponents.sdk.Client
+
+class RustRoomDirectoryService(
+ private val client: Client,
+ private val sessionDispatcher: CoroutineDispatcher,
+) : RoomDirectoryService {
+ override fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList {
+ return RustRoomDirectoryList(client.roomDirectorySearch(), scope, sessionDispatcher)
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
index 157e751d21..3d2fe0cc40 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt
@@ -18,90 +18,123 @@ package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
-import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
-import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
+import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.RecoveryState
import org.matrix.rustcomponents.sdk.RecoveryStateListener
import org.matrix.rustcomponents.sdk.SessionVerificationController
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
-import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
-import org.matrix.rustcomponents.sdk.TaskHandle
+import org.matrix.rustcomponents.sdk.VerificationState
+import org.matrix.rustcomponents.sdk.VerificationStateListener
import org.matrix.rustcomponents.sdk.use
+import timber.log.Timber
+import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
class RustSessionVerificationService(
- client: Client,
- private val syncService: RustSyncService,
+ private val client: Client,
+ isSyncServiceReady: Flow,
private val sessionCoroutineScope: CoroutineScope,
+ private val sessionStore: SessionStore,
) : SessionVerificationService, SessionVerificationControllerDelegate {
- private var recoveryStateListenerTaskHandle: TaskHandle? = null
private val encryptionService: Encryption = client.encryption()
- var verificationController: SessionVerificationControllerInterface? = null
- set(value) {
- field = value
- _isReady.value = value != null
- // If status was 'Unknown', move it to either 'Verified' or 'NotVerified'
- if (value != null) {
- value.setDelegate(this)
- sessionCoroutineScope.launch { updateVerificationStatus(value.isVerified()) }
- }
+ private lateinit var verificationController: SessionVerificationController
+
+ // Listen for changes in verification status and update accordingly
+ private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
+ override fun onUpdate(status: VerificationState) {
+ Timber.d("New verification state: $status")
+ updateVerificationStatus(status)
}
+ })
+
+ // In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered
+ private val recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
+ override fun onUpdate(status: RecoveryState) {
+ Timber.d("New recovery state: $status")
+ // We could check the `RecoveryState`, but it's easier to just use the verification state directly
+ updateVerificationStatus(encryptionService.verificationState())
+ }
+ })
+
+ override val needsVerificationFlow: StateFlow = sessionStore.sessionsFlow()
+ .map { sessions -> sessions.firstOrNull { it.userId == client.userId() }?.needsVerification.orFalse() }
+ .distinctUntilChanged()
+ .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
private val _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial)
override val verificationFlowState = _verificationFlowState.asStateFlow()
- private val _isReady = MutableStateFlow(false)
- override val isReady = _isReady.asStateFlow()
-
private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus.asStateFlow()
- override val canVerifySessionFlow = combine(sessionVerifiedStatus, syncService.syncState) { verificationStatus, syncState ->
- syncState == SyncState.Running && verificationStatus == SessionVerifiedStatus.NotVerified
+ override val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
+
+ override val canVerifySessionFlow = combine(sessionVerifiedStatus, isReady) { verificationStatus, isReady ->
+ isReady && verificationStatus == SessionVerifiedStatus.NotVerified
}
- fun start() {
- recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
- override fun onUpdate(status: RecoveryState) {
- sessionCoroutineScope.launch {
- updateVerificationStatus(verificationController?.isVerified().orFalse())
- }
+ init {
+ isReady.onEach { isReady ->
+ if (isReady) {
+ Timber.d("Starting verification service")
+ // Immediate status update
+ updateVerificationStatus(encryptionService.verificationState())
+ } else {
+ Timber.d("Stopping verification service")
}
- })
+ }
+ .launchIn(sessionCoroutineScope)
}
override suspend fun requestVerification() = tryOrFail {
- verificationController?.requestVerification()
+ if (!this::verificationController.isInitialized) {
+ verificationController = client.getSessionVerificationController()
+ verificationController.setDelegate(this)
+ }
+ verificationController.requestVerification()
}
- override suspend fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() }
+ override suspend fun cancelVerification() = tryOrFail { verificationController.cancelVerification() }
- override suspend fun approveVerification() = tryOrFail { verificationController?.approveVerification() }
+ override suspend fun approveVerification() = tryOrFail { verificationController.approveVerification() }
- override suspend fun declineVerification() = tryOrFail { verificationController?.declineVerification() }
+ override suspend fun declineVerification() = tryOrFail { verificationController.declineVerification() }
override suspend fun startVerification() = tryOrFail {
- verificationController?.startSasVerification()
+ verificationController.startSasVerification()
}
private suspend fun tryOrFail(block: suspend () -> Unit) {
runCatching {
block()
- }.onFailure { didFail() }
+ }.onFailure {
+ Timber.e(it, "Failed to verify session")
+ didFail()
+ }
}
// region Delegate implementation
@@ -116,13 +149,31 @@ class RustSessionVerificationService(
}
override fun didFail() {
+ Timber.e("Session verification failed with an unknown error")
_verificationFlowState.value = VerificationFlowState.Failed
}
override fun didFinish() {
- _verificationFlowState.value = VerificationFlowState.Finished
- // Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false
- updateVerificationStatus(isVerified = true)
+ sessionCoroutineScope.launch {
+ // Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it returns false if run immediately
+ // It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed
+ runCatching {
+ withTimeout(30.seconds) {
+ while (!verificationController.isVerified()) {
+ delay(100)
+ }
+ }
+ }
+ .onSuccess {
+ saveVerifiedState(true)
+ updateVerificationStatus(VerificationState.VERIFIED)
+ _verificationFlowState.value = VerificationFlowState.Finished
+ }
+ .onFailure {
+ Timber.e(it, "Verification finished, but the Rust SDK still reports the session as unverified.")
+ didFail()
+ }
+ }
}
override fun didReceiveVerificationData(data: RustSessionVerificationData) {
@@ -139,25 +190,35 @@ class RustSessionVerificationService(
override suspend fun reset() {
if (isReady.value) {
// Cancel any pending verification attempt
- tryOrNull { verificationController?.cancelVerification() }
+ tryOrNull { verificationController.cancelVerification() }
}
_verificationFlowState.value = VerificationFlowState.Initial
}
- fun destroy() {
- recoveryStateListenerTaskHandle?.cancelAndDestroy()
- verificationController?.setDelegate(null)
- (verificationController as? SessionVerificationController)?.destroy()
- verificationController = null
+ override suspend fun saveVerifiedState(verified: Boolean) = tryOrFail {
+ val existingSession = sessionStore.getSession(client.userId())
+ ?: error("Failed to save verification state. No session with id ${client.userId()}")
+ sessionStore.updateData(existingSession.copy(needsVerification = !verified))
+ // Wait until the new state is saved
+ needsVerificationFlow.first { needsVerification -> !needsVerification }
}
- private fun updateVerificationStatus(isVerified: Boolean) {
- val newValue = when {
- !isReady.value -> SessionVerifiedStatus.Unknown
- !isVerified -> SessionVerifiedStatus.NotVerified
- else -> SessionVerifiedStatus.Verified
+ fun destroy() {
+ Timber.d("Destroying RustSessionVerificationService")
+ verificationStateListenerTaskHandle.cancelAndDestroy()
+ recoveryStateListenerTaskHandle.cancelAndDestroy()
+ if (this::verificationController.isInitialized) {
+ verificationController.setDelegate(null)
+ verificationController.destroy()
+ }
+ }
+
+ private fun updateVerificationStatus(verificationState: VerificationState) {
+ _sessionVerifiedStatus.value = when (verificationState) {
+ VerificationState.UNKNOWN -> SessionVerifiedStatus.Unknown
+ VerificationState.VERIFIED -> SessionVerifiedStatus.Verified
+ VerificationState.UNVERIFIED -> SessionVerifiedStatus.NotVerified
}
- _sessionVerifiedStatus.value = newValue
}
}
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
similarity index 71%
rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt
rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
index cbf1bffc88..7401ac7c2e 100644
--- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverterTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.libraries.matrix.api.permalink
+package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import com.google.common.truth.Truth.assertThat
@@ -23,34 +23,34 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
-class MatrixToConverterTest {
+class DefaultMatrixToConverterTest {
@Test
fun `converting a matrix-to url does nothing`() {
val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org")
- assertThat(MatrixToConverter.convert(url)).isEqualTo(url)
+ assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(url)
}
@Test
fun `converting a url with a supported room path returns a matrix-to url`() {
val url = Uri.parse("https://riot.im/develop/#/room/#element-android:matrix.org")
- assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org"))
+ assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org"))
}
@Test
fun `converting a url with a supported user path returns a matrix-to url`() {
val url = Uri.parse("https://riot.im/develop/#/user/@test:matrix.org")
- assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org"))
+ assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org"))
}
@Test
fun `converting a url with a supported group path returns a matrix-to url`() {
val url = Uri.parse("https://riot.im/develop/#/group/+group:matrix.org")
- assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org"))
+ assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org"))
}
@Test
fun `converting an unsupported url returns null`() {
val url = Uri.parse("https://element.io/")
- assertThat(MatrixToConverter.convert(url)).isNull()
+ assertThat(DefaultMatrixToConverter().convert(url)).isNull()
}
}
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt
similarity index 70%
rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt
rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt
index 2b5b29e084..c861b3105c 100644
--- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilderTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilderTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.libraries.matrix.api.permalink
+package io.element.android.libraries.matrix.impl.permalink
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.metadata.withReleaseBehavior
@@ -23,18 +23,18 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.assertThrowsInDebug
import org.junit.Test
-class PermalinkBuilderTest {
+class DefaultPermalinkBuilderTest {
fun `building a permalink for an invalid user id throws when verifying the id`() {
assertThrowsInDebug {
val userId = UserId("some invalid user id")
- PermalinkBuilder.permalinkForUser(userId)
+ DefaultPermalinkBuilder().permalinkForUser(userId)
}
}
fun `building a permalink for an invalid room id throws when verifying the id`() {
assertThrowsInDebug {
val roomId = RoomId("some invalid room id")
- PermalinkBuilder.permalinkForRoomId(roomId)
+ DefaultPermalinkBuilder().permalinkForRoomId(roomId)
}
}
@@ -42,7 +42,7 @@ class PermalinkBuilderTest {
fun `building a permalink for an invalid user id returns failure when not verifying the id`() {
withReleaseBehavior {
val userId = UserId("some invalid user id")
- assertThat(PermalinkBuilder.permalinkForUser(userId).isFailure).isTrue()
+ assertThat(DefaultPermalinkBuilder().permalinkForUser(userId).isFailure).isTrue()
}
}
@@ -50,31 +50,31 @@ class PermalinkBuilderTest {
fun `building a permalink for an invalid room id returns failure when not verifying the id`() {
withReleaseBehavior {
val roomId = RoomId("some invalid room id")
- assertThat(PermalinkBuilder.permalinkForRoomId(roomId).isFailure).isTrue()
+ assertThat(DefaultPermalinkBuilder().permalinkForRoomId(roomId).isFailure).isTrue()
}
}
@Test
fun `building a permalink for an invalid room alias returns failure`() {
val roomAlias = "an invalid room alias"
- assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).isFailure).isTrue()
+ assertThat(DefaultPermalinkBuilder().permalinkForRoomAlias(roomAlias).isFailure).isTrue()
}
@Test
fun `building a permalink for a valid user id returns a matrix-to url`() {
val userId = UserId("@user:matrix.org")
- assertThat(PermalinkBuilder.permalinkForUser(userId).getOrNull()).isEqualTo("https://matrix.to/#/@user:matrix.org")
+ assertThat(DefaultPermalinkBuilder().permalinkForUser(userId).getOrNull()).isEqualTo("https://matrix.to/#/@user:matrix.org")
}
@Test
fun `building a permalink for a valid room id returns a matrix-to url`() {
val roomId = RoomId("!aBCdEFG1234:matrix.org")
- assertThat(PermalinkBuilder.permalinkForRoomId(roomId).getOrNull()).isEqualTo("https://matrix.to/#/!aBCdEFG1234:matrix.org")
+ assertThat(DefaultPermalinkBuilder().permalinkForRoomId(roomId).getOrNull()).isEqualTo("https://matrix.to/#/!aBCdEFG1234:matrix.org")
}
@Test
fun `building a permalink for a valid room alias returns a matrix-to url`() {
val roomAlias = "#room:matrix.org"
- assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()).isEqualTo("https://matrix.to/#/#room:matrix.org")
+ assertThat(DefaultPermalinkBuilder().permalinkForRoomAlias(roomAlias).getOrNull()).isEqualTo("https://matrix.to/#/#room:matrix.org")
}
}
diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt
similarity index 70%
rename from libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt
rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt
index 66cdaf88ee..1e9e3bc2dc 100644
--- a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParserTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParserTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,44 +14,60 @@
* limitations under the License.
*/
-package io.element.android.libraries.matrix.api.permalink
+package io.element.android.libraries.matrix.impl.permalink
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
-class PermalinkParserTest {
+class DefaultPermalinkParserTest {
@Test
fun `parsing an invalid url returns a fallback link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://element.io"
- assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+ assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing an invalid url with the right path but no content returns a fallback link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/user"
- assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+ assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing an invalid url with the right path but empty content returns a fallback link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/user/"
- assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+ assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing an invalid url with the right path but invalid content returns a fallback link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/user/some%20user!"
- assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+ assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing a valid user url returns a user link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/user/@test:matrix.org"
- assertThat(PermalinkParser.parse(url)).isEqualTo(
+ assertThat(sut.parse(url)).isEqualTo(
PermalinkData.UserLink(
userId = "@test:matrix.org"
)
@@ -60,8 +76,11 @@ class PermalinkParserTest {
@Test
fun `parsing a valid room id url returns a room link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org"
- assertThat(PermalinkParser.parse(url)).isEqualTo(
+ assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
@@ -73,8 +92,11 @@ class PermalinkParserTest {
@Test
fun `parsing a valid room id with event id url returns a room link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org"
- assertThat(PermalinkParser.parse(url)).isEqualTo(
+ assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
@@ -86,8 +108,11 @@ class PermalinkParserTest {
@Test
fun `parsing a valid room id with and invalid event id url returns a room link with no event id`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/1234567890abcdef:matrix.org"
- assertThat(PermalinkParser.parse(url)).isEqualTo(
+ assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
@@ -99,8 +124,11 @@ class PermalinkParserTest {
@Test
fun `parsing a valid room id with event id and via parameters url returns a room link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org?via=matrix.org&via=matrix.com"
- assertThat(PermalinkParser.parse(url)).isEqualTo(
+ assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
@@ -112,8 +140,11 @@ class PermalinkParserTest {
@Test
fun `parsing a valid room alias url returns a room link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/room/#element-android:matrix.org"
- assertThat(PermalinkParser.parse(url)).isEqualTo(
+ assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "#element-android:matrix.org",
isRoomAlias = true,
@@ -125,6 +156,9 @@ class PermalinkParserTest {
@Test
fun `parsing a url with an invalid signurl returns a fallback link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
// This url has no private key
val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
"?email=testuser%40element.io" +
@@ -135,11 +169,14 @@ class PermalinkParserTest {
"&guest_access_token=" +
"&guest_user_id=" +
"&room_type="
- assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
+ assertThat(sut.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing a url with signurl returns a room email invite link`() {
+ val sut = DefaultPermalinkParser(
+ matrixToConverter = DefaultMatrixToConverter(),
+ )
val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
"?email=testuser%40element.io" +
"&signurl=https%3A%2F%2Fvector.im%2F_matrix%2Fidentity%2Fapi%2Fv1%2Fsign-ed25519%3Ftoken%3Da_token%26private_key%3Da_private_key" +
@@ -149,7 +186,7 @@ class PermalinkParserTest {
"&guest_access_token=" +
"&guest_user_id=" +
"&room_type="
- assertThat(PermalinkParser.parse(url)).isEqualTo(
+ assertThat(sut.parse(url)).isEqualTo(
PermalinkData.RoomEmailInviteLink(
roomId = "!aBCDEF12345:matrix.org",
email = "testuser@element.io",
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index d7c3f71e64..b485257815 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -39,6 +40,7 @@ import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.pushers.FakePushersService
+import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -65,6 +67,7 @@ class FakeMatrixClient(
private val notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private val syncService: FakeSyncService = FakeSyncService(),
private val encryptionService: FakeEncryptionService = FakeEncryptionService(),
+ private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
private val accountManagementUrlString: Result = Result.success(null),
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
@@ -91,6 +94,9 @@ class FakeMatrixClient(
private var setDisplayNameResult: Result = Result.success(Unit)
private var uploadAvatarResult: Result = Result.success(Unit)
private var removeAvatarResult: Result = Result.success(Unit)
+ var joinRoomLambda: suspend (RoomId) -> Result = {
+ Result.success(it)
+ }
override suspend fun getRoom(roomId: RoomId): MatrixRoom? {
return getRoomResults[roomId]
@@ -126,6 +132,8 @@ class FakeMatrixClient(
override fun syncService() = syncService
+ override fun roomDirectoryService() = roomDirectoryService
+
override suspend fun getCacheSize(): Long {
return 0
}
@@ -176,6 +184,8 @@ class FakeMatrixClient(
return removeAvatarResult
}
+ override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId)
+
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun pushersService(): PushersService = pushersService
@@ -245,4 +255,16 @@ class FakeMatrixClient(
fun givenRemoveAvatarResult(result: Result) {
removeAvatarResult = result
}
+
+ private val visitedRoomsId: MutableList = mutableListOf()
+
+ override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result {
+ visitedRoomsId.removeAll { it == roomId }
+ visitedRoomsId.add(0, roomId)
+ return Result.success(Unit)
+ }
+
+ override suspend fun getRecentlyVisitedRooms(): Result> {
+ return Result.success(visitedRoomsId)
+ }
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
new file mode 100644
index 0000000000..4e6d3375e1
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.permalink
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
+
+class FakePermalinkBuilder(
+ private val result: () -> Result = { Result.failure(Exception("Not implemented")) }
+) : PermalinkBuilder {
+ override fun permalinkForUser(userId: UserId): Result {
+ return result()
+ }
+
+ override fun permalinkForRoomAlias(roomAlias: String): Result {
+ return result()
+ }
+
+ override fun permalinkForRoomId(roomId: RoomId): Result {
+ return result()
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt
new file mode 100644
index 0000000000..f046eadf93
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.permalink
+
+import android.net.Uri
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
+
+class FakePermalinkParser(
+ private var result: () -> PermalinkData = { TODO("Not implemented") }
+) : PermalinkParser {
+ fun givenResult(result: PermalinkData) {
+ this.result = { result }
+ }
+
+ override fun parse(uriString: String): PermalinkData {
+ return result()
+ }
+
+ override fun parse(uri: Uri): PermalinkData {
+ TODO("Not yet implemented")
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 1872d2e60d..11582433d5 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -77,12 +77,14 @@ class FakeMatrixRoom(
override val alias: String? = null,
override val alternativeAliases: List = emptyList(),
override val isPublic: Boolean = true,
+ override val isSpace: Boolean = false,
override val isDirect: Boolean = false,
override val isOneToOne: Boolean = false,
override val joinedMemberCount: Long = 123L,
override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
+ private var permalinkResult: () -> Result = { Result.success("link") },
canRedactOwn: Boolean = false,
canRedactOther: Boolean = false,
) : MatrixRoom {
@@ -199,6 +201,10 @@ class FakeMatrixRoom(
return getRoomMemberResult
}
+ override suspend fun getMembers(limit: Int): Result> {
+ return Result.success(emptyList())
+ }
+
override suspend fun updateRoomNotificationSettings(): Result = simulateLongTask {
val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings)
@@ -272,6 +278,10 @@ class FakeMatrixRoom(
return cancelSendResult
}
+ override suspend fun getPermalinkFor(eventId: EventId): Result {
+ return permalinkResult()
+ }
+
override suspend fun editMessage(
originalEventId: EventId?,
transactionId: TransactionId?,
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt
new file mode 100644
index 0000000000..b01501d328
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+
+class FakeRoomDirectoryList(
+ override val state: Flow = emptyFlow(),
+ val filterLambda: (String?, Int) -> Result = { _, _ -> Result.success(Unit) },
+ val loadMoreLambda: () -> Result = { Result.success(Unit) }
+) : RoomDirectoryList {
+ override suspend fun filter(filter: String?, batchSize: Int) = filterLambda(filter, batchSize)
+
+ override suspend fun loadMore(): Result = loadMoreLambda()
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt
new file mode 100644
index 0000000000..68926b9deb
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.roomdirectory
+
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
+import kotlinx.coroutines.CoroutineScope
+
+class FakeRoomDirectoryService(
+ private val createRoomDirectoryListFactory: (CoroutineScope) -> RoomDirectoryList = { throw AssertionError("Configure a proper factory.") }
+) : RoomDirectoryService {
+ override fun createRoomDirectoryList(scope: CoroutineScope) = createRoomDirectoryListFactory(scope)
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt
new file mode 100644
index 0000000000..6e96ca4452
--- /dev/null
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.matrix.test.roomdirectory
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+
+fun aRoomDescription(
+ roomId: RoomId = A_ROOM_ID,
+ name: String? = null,
+ topic: String? = null,
+ alias: String? = null,
+ avatarUrl: String? = null,
+ joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN,
+ isWorldReadable: Boolean = true,
+ joinedMembers: Long = 2L
+) = RoomDescription(
+ roomId = roomId,
+ name = name,
+ topic = topic,
+ alias = alias,
+ avatarUrl = avatarUrl,
+ joinRule = joinRule,
+ isWorldReadable = isWorldReadable,
+ numberOfMembers = joinedMembers
+)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt
index 4a7aa1c304..7823910263 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt
@@ -20,17 +20,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
+import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
+import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-class FakeSessionVerificationService : SessionVerificationService {
+class FakeSessionVerificationService(
+ var saveVerifiedStateResult: LambdaOneParamRecorder = lambdaRecorder {}
+) : SessionVerificationService {
private val _isReady = MutableStateFlow(false)
private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown)
private var _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial)
private var _canVerifySessionFlow = MutableStateFlow(true)
var shouldFail = false
+ override val needsVerificationFlow: MutableStateFlow = MutableStateFlow(false)
override val verificationFlowState: StateFlow = _verificationFlowState
override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus
override val canVerifySessionFlow: Flow = _canVerifySessionFlow
@@ -38,7 +43,11 @@ class FakeSessionVerificationService : SessionVerificationService {
override val isReady: StateFlow = _isReady
override suspend fun requestVerification() {
- _verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
+ if (!shouldFail) {
+ _verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
+ } else {
+ _verificationFlowState.value = VerificationFlowState.Failed
+ }
}
override suspend fun cancelVerification() {
@@ -85,7 +94,15 @@ class FakeSessionVerificationService : SessionVerificationService {
_isReady.value = value
}
+ fun givenNeedsVerification(value: Boolean) {
+ needsVerificationFlow.value = value
+ }
+
override suspend fun reset() {
_verificationFlowState.value = VerificationFlowState.Initial
}
+
+ override suspend fun saveVerifiedState(verified: Boolean) {
+ saveVerifiedStateResult(verified)
+ }
}
diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts
index 669f9c9634..fa7a946959 100644
--- a/libraries/matrixui/build.gradle.kts
+++ b/libraries/matrixui/build.gradle.kts
@@ -47,4 +47,5 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
+ testImplementation(projects.libraries.matrix.test)
}
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
index b3c6a0bf86..36ae94d7f7 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
@@ -29,9 +29,13 @@ import org.jsoup.nodes.Document
*
* This will also make sure mentions are prefixed with `@`.
*
+ * @param permalinkParser the parser to use to parse the mentions.
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/
-fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
+fun FormattedBody.toHtmlDocument(
+ permalinkParser: PermalinkParser,
+ prefix: String? = null,
+): Document? {
return takeIf { it.format == MessageFormat.HTML }?.body
// Trim whitespace at the end to avoid having wrong rendering of the message.
// We don't trim the start in case it's used as indentation.
@@ -44,17 +48,20 @@ fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
}
// Prepend `@` to mentions
- fixMentions(dom)
+ fixMentions(dom, permalinkParser)
dom
}
}
-private fun fixMentions(dom: Document) {
+private fun fixMentions(
+ dom: Document,
+ permalinkParser: PermalinkParser,
+) {
val links = dom.getElementsByTag("a")
links.forEach {
if (it.hasAttr("href")) {
- val link = PermalinkParser.parse(it.attr("href"))
+ val link = permalinkParser.parse(it.attr("href"))
if (link is PermalinkData.UserLink && !it.text().startsWith("@")) {
it.prependText("@")
}
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt
index 0cea2d7ae2..1eb581af65 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.ui.messages
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@@ -29,15 +30,24 @@ import org.jsoup.select.NodeVisitor
* Converts the HTML string in [TextMessageType.formatted] to a plain text representation by parsing it and removing all formatting.
* If the message is not formatted or the format is not [MessageFormat.HTML], the [TextMessageType.body] is returned instead.
*/
-fun TextMessageType.toPlainText() = formatted?.toPlainText() ?: body
+fun TextMessageType.toPlainText(
+ permalinkParser: PermalinkParser,
+) = formatted?.toPlainText(permalinkParser) ?: body
/**
* Converts the HTML string in [FormattedBody.body] to a plain text representation by parsing it and removing all formatting.
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
+ * @param permalinkParser the parser to use to parse the mentions.
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/
-fun FormattedBody.toPlainText(prefix: String? = null): String? {
- return this.toHtmlDocument(prefix)?.toPlainText()
+fun FormattedBody.toPlainText(
+ permalinkParser: PermalinkParser,
+ prefix: String? = null,
+): String? {
+ return this.toHtmlDocument(
+ permalinkParser = permalinkParser,
+ prefix = prefix,
+ )?.toPlainText()
}
/**
diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt
index 704b87c593..ab83f77e66 100644
--- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt
+++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToHtmlDocumentTest.kt
@@ -16,9 +16,13 @@
package io.element.android.libraries.matrixui.messages
+import android.net.Uri
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.permalink.PermalinkData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import org.junit.Test
import org.junit.runner.RunWith
@@ -33,7 +37,7 @@ class ToHtmlDocumentTest {
body = "Hello world"
)
- val document = body.toHtmlDocument()
+ val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document).isNull()
}
@@ -45,7 +49,7 @@ class ToHtmlDocumentTest {
body = "Hello world
"
)
- val document = body.toHtmlDocument()
+ val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("Hello world")
}
@@ -57,7 +61,10 @@ class ToHtmlDocumentTest {
body = "Hello world
"
)
- val document = body.toHtmlDocument(prefix = "@Jorge:")
+ val document = body.toHtmlDocument(
+ permalinkParser = FakePermalinkParser(),
+ prefix = "@Jorge:"
+ )
assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("@Jorge: Hello world")
}
@@ -69,7 +76,13 @@ class ToHtmlDocumentTest {
body = "Hey Alice!"
)
- val document = body.toHtmlDocument()
+ val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser {
+ override fun parse(uriString: String): PermalinkData {
+ return PermalinkData.UserLink("@alice:matrix.org")
+ }
+
+ override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented")
+ })
assertThat(document?.text()).isEqualTo("Hey @Alice!")
}
@@ -80,7 +93,13 @@ class ToHtmlDocumentTest {
body = "Hey @Alice!"
)
- val document = body.toHtmlDocument()
+ val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser {
+ override fun parse(uriString: String): PermalinkData {
+ return PermalinkData.UserLink("@alice:matrix.org")
+ }
+
+ override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented")
+ })
assertThat(document?.text()).isEqualTo("Hey @Alice!")
}
@@ -91,7 +110,13 @@ class ToHtmlDocumentTest {
body = "Hey Alice!"
)
- val document = body.toHtmlDocument()
+ val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser {
+ override fun parse(uriString: String): PermalinkData {
+ return PermalinkData.FallbackLink(uri = Uri.parse("https://matrix.org"))
+ }
+
+ override fun parse(uri: Uri): PermalinkData = TODO("Not yet implemented")
+ })
assertThat(document?.text()).isEqualTo("Hey Alice!")
}
}
diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt
index 7c61075584..74e43a9e39 100644
--- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt
+++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrixui/messages/ToPlainTextTest.kt
@@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
+import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.Jsoup
import org.junit.Test
@@ -59,7 +60,7 @@ class ToPlainTextTest {
""".trimIndent()
)
- assertThat(formattedBody.toPlainText()).isEqualTo(
+ assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
"""
Hello world
• This is an unordered list.
@@ -79,7 +80,7 @@ class ToPlainTextTest {
""".trimIndent()
)
- assertThat(formattedBody.toPlainText()).isNull()
+ assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isNull()
}
@Test
@@ -96,7 +97,7 @@ class ToPlainTextTest {
""".trimIndent()
)
)
- assertThat(messageType.toPlainText()).isEqualTo(
+ assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
"""
Hello world
• This is an unordered list.
@@ -119,6 +120,6 @@ class ToPlainTextTest {
""".trimIndent()
)
)
- assertThat(messageType.toPlainText()).isEqualTo("This is the fallback text")
+ assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the fallback text")
}
}
diff --git a/libraries/permissions/api/build.gradle.kts b/libraries/permissions/api/build.gradle.kts
index 32d3776419..cb60c6c0a7 100644
--- a/libraries/permissions/api/build.gradle.kts
+++ b/libraries/permissions/api/build.gradle.kts
@@ -24,7 +24,6 @@ android {
dependencies {
implementation(projects.libraries.architecture)
-
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
diff --git a/libraries/permissions/api/src/main/res/values-hu/translations.xml b/libraries/permissions/api/src/main/res/values-hu/translations.xml
index aea9a1f772..de3573fce2 100644
--- a/libraries/permissions/api/src/main/res/values-hu/translations.xml
+++ b/libraries/permissions/api/src/main/res/values-hu/translations.xml
@@ -1,7 +1,7 @@
"Hogy az alkalmazás használhassa a kamerát, adja meg az engedélyt a rendszerbeállításokban."
- "Add meg az engedélyt a rendszerbeállításokban."
+ "Adja meg az engedélyt a rendszerbeállításokban."
"Hogy az alkalmazás használhassa a mikrofont, adja meg az engedélyt a rendszerbeállításokban."
"Hogy az alkalmazás megjeleníthesse az értesítéseket, adja meg az engedélyt a rendszerbeállításokban."
diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts
index 7d05d9a1d7..7b80ef0d4f 100644
--- a/libraries/permissions/impl/build.gradle.kts
+++ b/libraries/permissions/impl/build.gradle.kts
@@ -46,8 +46,10 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.troubleshoot.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
+ implementation(projects.services.toolbox.api)
api(projects.libraries.permissions.api)
testImplementation(libs.test.junit)
@@ -57,6 +59,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt
index c05df3de46..61a82aafff 100644
--- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt
+++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt
@@ -17,6 +17,8 @@
package io.element.android.libraries.permissions.impl
import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
@@ -33,7 +35,10 @@ class DefaultPermissionStateProvider @Inject constructor(
private val permissionsStore: PermissionsStore,
) : PermissionStateProvider {
override fun isPermissionGranted(permission: String): Boolean {
- return context.checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ return ContextCompat.checkSelfPermission(
+ context,
+ permission,
+ ) == PackageManager.PERMISSION_GRANTED
}
override suspend fun setPermissionDenied(permission: String, value: Boolean) = permissionsStore.setPermissionDenied(permission, value)
diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt
index 6370e839a3..405b61a809 100644
--- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt
+++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt
@@ -18,7 +18,7 @@ package io.element.android.libraries.permissions.impl.action
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.libraries.androidutils.system.openAppSettingsPage
+import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
@@ -28,6 +28,6 @@ class AndroidPermissionActions @Inject constructor(
@ApplicationContext private val context: Context
) : PermissionActions {
override fun openSettings() {
- context.openAppSettingsPage()
+ context.startNotificationSettingsIntent()
}
}
diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt
new file mode 100644
index 0000000000..7e916c37b5
--- /dev/null
+++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.permissions.impl.troubleshoot
+
+import android.Manifest
+import android.os.Build
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.permissions.api.PermissionStateProvider
+import io.element.android.libraries.permissions.impl.R
+import io.element.android.libraries.permissions.impl.action.PermissionActions
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
+import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
+import io.element.android.services.toolbox.api.strings.StringProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+
+@ContributesMultibinding(AppScope::class)
+class NotificationTroubleshootCheckPermissionTest @Inject constructor(
+ private val permissionStateProvider: PermissionStateProvider,
+ private val sdkVersionProvider: BuildVersionSdkIntProvider,
+ private val permissionActions: PermissionActions,
+ private val stringProvider: StringProvider,
+) : NotificationTroubleshootTest {
+ override val order: Int = 0
+
+ private val delegate = NotificationTroubleshootTestDelegate(
+ defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_check_permission_title),
+ defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_check_permission_description),
+ hasQuickFix = true,
+ fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
+ )
+
+ override val state: StateFlow = delegate.state
+
+ override suspend fun run(coroutineScope: CoroutineScope) {
+ delegate.start()
+ val result = if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
+ permissionStateProvider.isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ true
+ }
+ delegate.done(result)
+ }
+
+ override suspend fun reset() = delegate.reset()
+
+ override suspend fun quickFix(coroutineScope: CoroutineScope) {
+ // Do not bother about asking the permission inline, just lead the user to the settings
+ permissionActions.openSettings()
+ }
+}
diff --git a/libraries/permissions/impl/src/main/res/values-be/translations.xml b/libraries/permissions/impl/src/main/res/values-be/translations.xml
new file mode 100644
index 0000000000..44c9d8376a
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values-be/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Пераканайцеся, што праграма можа паказваць апавяшчэнні."
+ "Праверце дазволы"
+
diff --git a/libraries/permissions/impl/src/main/res/values-cs/translations.xml b/libraries/permissions/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..038561d93b
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Ujistěte se, že aplikace může zobrazovat oznámení."
+ "Kontrola oprávnění"
+
diff --git a/libraries/permissions/impl/src/main/res/values-de/translations.xml b/libraries/permissions/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..a0ff0ff417
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Stelle sicher, dass die Anwendung Benachrichtigungen anzeigen kann."
+ "Berechtigungen überprüfen"
+
diff --git a/libraries/permissions/impl/src/main/res/values-fr/translations.xml b/libraries/permissions/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..b2cd018579
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Vérifie que l’application peut afficher des notifications."
+ "Vérifier les autorisations"
+
diff --git a/libraries/permissions/impl/src/main/res/values-hu/translations.xml b/libraries/permissions/impl/src/main/res/values-hu/translations.xml
new file mode 100644
index 0000000000..4afe7f181b
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values-hu/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Ellenőrizze, hogy az alkalmazás képes-e értesítéseket megjeleníteni."
+ "Engedélyek ellenőrzése"
+
diff --git a/libraries/permissions/impl/src/main/res/values-ru/translations.xml b/libraries/permissions/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..d1acff7ebd
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Убедитесь, что приложение может показывать уведомления."
+ "Проверка разрешений"
+
diff --git a/libraries/permissions/impl/src/main/res/values-sk/translations.xml b/libraries/permissions/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..995754907e
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Uistite sa, že aplikácia dokáže zobrazovať upozornenia."
+ "Skontrolovať povolenia"
+
diff --git a/libraries/permissions/impl/src/main/res/values/localazy.xml b/libraries/permissions/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..948b026970
--- /dev/null
+++ b/libraries/permissions/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,5 @@
+
+
+ "Check that the application can show notifications."
+ "Check permissions"
+
diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt
index fa17329900..f6709a7f3d 100644
--- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt
+++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt
@@ -16,11 +16,14 @@
package io.element.android.libraries.permissions.impl.action
-class FakePermissionActions : PermissionActions {
+class FakePermissionActions(
+ val openSettingsAction: () -> Unit = {}
+) : PermissionActions {
var openSettingsCalled = false
private set
override fun openSettings() {
+ openSettingsAction()
openSettingsCalled = true
}
}
diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt
new file mode 100644
index 0000000000..b38d00ee22
--- /dev/null
+++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.permissions.impl.troubleshoot
+
+import android.os.Build
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
+import io.element.android.libraries.permissions.impl.action.FakePermissionActions
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
+import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
+import io.element.android.services.toolbox.test.strings.FakeStringProvider
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class NotificationTroubleshootCheckPermissionTestTest {
+ @Test
+ fun `test NotificationTroubleshootCheckPermissionTest below TIRAMISU success`() = runTest {
+ val sut = NotificationTroubleshootCheckPermissionTest(
+ permissionStateProvider = FakePermissionStateProvider(),
+ sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU - 1),
+ permissionActions = FakePermissionActions(),
+ stringProvider = FakeStringProvider(),
+ )
+ launch {
+ sut.run(this)
+ }
+ sut.state.test {
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true))
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
+ val lastItem = awaitItem()
+ assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success)
+ }
+ }
+
+ @Test
+ fun `test NotificationTroubleshootCheckPermissionTest TIRAMISU success`() = runTest {
+ val sut = NotificationTroubleshootCheckPermissionTest(
+ permissionStateProvider = FakePermissionStateProvider(),
+ sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU),
+ permissionActions = FakePermissionActions(),
+ stringProvider = FakeStringProvider(),
+ )
+ launch {
+ sut.run(this)
+ }
+ sut.state.test {
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true))
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
+ val lastItem = awaitItem()
+ assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success)
+ }
+ }
+
+ @Test
+ fun `test NotificationTroubleshootCheckPermissionTest TIRAMISU error`() = runTest {
+ val permissionStateProvider = FakePermissionStateProvider(
+ permissionGranted = false
+ )
+ val actions = FakePermissionActions(
+ openSettingsAction = {
+ permissionStateProvider.setPermissionGranted()
+ }
+ )
+ val sut = NotificationTroubleshootCheckPermissionTest(
+ permissionStateProvider = permissionStateProvider,
+ sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU),
+ permissionActions = actions,
+ stringProvider = FakeStringProvider(),
+ )
+ launch {
+ sut.run(this)
+ }
+ sut.state.test {
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true))
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
+ val lastItem = awaitItem()
+ assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(true))
+ // Quick fix
+ launch {
+ sut.quickFix(this)
+ // Run the test again (IRL it will be done thanks to the resuming of the application)
+ sut.run(this)
+ }
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress)
+ assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success)
+ }
+ }
+}
diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt
similarity index 71%
rename from features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt
rename to libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt
index cd172669cc..0c6ed41929 100644
--- a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt
+++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2023 New Vector Ltd
+ * Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,12 +14,8 @@
* limitations under the License.
*/
-package io.element.android.features.ftue.api.state
+package io.element.android.libraries.push.api
-import kotlinx.coroutines.flow.StateFlow
-
-interface FtueState {
- val shouldDisplayFlow: StateFlow
-
- suspend fun reset()
+interface GetCurrentPushProvider {
+ suspend fun getCurrentPushProvider(): String?
}
diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt
index 5f4736e5ab..abfc328e9f 100644
--- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt
+++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt
@@ -37,6 +37,8 @@ interface PushService {
*/
suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor)
- // TODO Move away
- suspend fun testPush()
+ /**
+ * Return false in case of early error.
+ */
+ suspend fun testPush(): Boolean
}
diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt
index c7814a1796..07cf9acf52 100644
--- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt
+++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt
@@ -16,6 +16,6 @@
package io.element.android.libraries.push.api.gateway
-sealed class PushGatewayFailure : Throwable(cause = null) {
- data object PusherRejected : PushGatewayFailure()
+sealed class PushGatewayFailure : Exception() {
+ class PusherRejected : PushGatewayFailure()
}
diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts
index 7df972c3c3..ee528a4ae7 100644
--- a/libraries/push/impl/build.gradle.kts
+++ b/libraries/push/impl/build.gradle.kts
@@ -53,6 +53,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.troubleshoot.api)
api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api)
api(projects.libraries.push.api)
@@ -69,6 +70,8 @@ dependencies {
testImplementation(libs.coil.test)
testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.push.test)
+ testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.impl)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt
new file mode 100644
index 0000000000..977d41caca
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.push.api.GetCurrentPushProvider
+import io.element.android.libraries.pushstore.api.UserPushStoreFactory
+import io.element.android.services.appnavstate.api.AppNavigationStateService
+import io.element.android.services.appnavstate.api.currentSessionId
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultGetCurrentPushProvider @Inject constructor(
+ private val pushStoreFactory: UserPushStoreFactory,
+ private val appNavigationStateService: AppNavigationStateService,
+) : GetCurrentPushProvider {
+ override suspend fun getCurrentPushProvider(): String? {
+ return appNavigationStateService
+ .appNavigationState
+ .value
+ .navigationState
+ .currentSessionId()
+ ?.let { pushStoreFactory.getOrCreate(it) }
+ ?.getPushProviderName()
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt
index e60cd8b014..cff18cfb3d 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt
@@ -19,6 +19,7 @@ package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.push.api.GetCurrentPushProvider
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.pushproviders.api.Distributor
@@ -32,6 +33,7 @@ class DefaultPushService @Inject constructor(
private val pushersManager: PushersManager,
private val userPushStoreFactory: UserPushStoreFactory,
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
+ private val getCurrentPushProvider: GetCurrentPushProvider,
) : PushService {
override fun notificationStyleChanged() {
defaultNotificationDrawerManager.notificationStyleChanged()
@@ -47,7 +49,7 @@ class DefaultPushService @Inject constructor(
* Get current push provider, compare with provided one, then unregister and register if different, and store change.
*/
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
- val userPushStore = userPushStoreFactory.create(matrixClient.sessionId)
+ val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
val currentPushProviderName = userPushStore.getPushProviderName()
if (currentPushProviderName != pushProvider.name) {
// Unregister previous one if any
@@ -58,7 +60,11 @@ class DefaultPushService @Inject constructor(
userPushStore.setPushProviderName(pushProvider.name)
}
- override suspend fun testPush() {
- pushersManager.testPush()
+ override suspend fun testPush(): Boolean {
+ val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider()
+ val pushProvider = pushProviders.find { it.name == currentPushProvider } ?: return false
+ val config = pushProvider.getCurrentUserPushConfig() ?: return false
+ pushersManager.testPush(config)
+ return true
}
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt
index d4424da492..4306072e48 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt
@@ -23,9 +23,11 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
+import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig
import io.element.android.libraries.pushproviders.api.PusherSubscriber
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
@@ -45,16 +47,14 @@ class PushersManager @Inject constructor(
private val pushClientSecret: PushClientSecret,
private val userPushStoreFactory: UserPushStoreFactory,
) : PusherSubscriber {
- // TODO Move this to the PushProvider API
- suspend fun testPush() {
+ suspend fun testPush(config: CurrentUserPushConfig) {
pushGatewayNotifyRequest.execute(
PushGatewayNotifyRequest.Params(
- // unifiedPushHelper.getPushGateway() ?: return
- url = "TODO",
+ url = config.url,
appId = PushConfig.PUSHER_APP_ID,
- // unifiedPushHelper.getEndpointOrToken().orEmpty()
- pushKey = "TODO",
- eventId = TEST_EVENT_ID
+ pushKey = config.pushKey,
+ eventId = TEST_EVENT_ID,
+ roomId = TEST_ROOM_ID,
)
)
}
@@ -63,7 +63,7 @@ class PushersManager @Inject constructor(
* Register a pusher to the server if not done yet.
*/
override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) {
- val userDataStore = userPushStoreFactory.create(matrixClient.sessionId)
+ val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
if (userDataStore.getCurrentRegisteredPushKey() == pushKey) {
Timber.tag(loggerTag.value)
.d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server")
@@ -112,5 +112,6 @@ class PushersManager @Inject constructor(
companion object {
val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID")
+ val TEST_ROOM_ID = RoomId("!room:domain")
}
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
index 3b2ac19313..8a285e3b32 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
+import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@@ -68,6 +69,7 @@ class NotifiableEventResolver @Inject constructor(
private val matrixClientProvider: MatrixClientProvider,
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
@ApplicationContext private val context: Context,
+ private val permalinkParser: PermalinkParser,
) {
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
// Restore session
@@ -252,7 +254,7 @@ class NotifiableEventResolver @Inject constructor(
is ImageMessageType -> messageType.body
is StickerMessageType -> messageType.body
is NoticeMessageType -> messageType.body
- is TextMessageType -> messageType.toPlainText()
+ is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser)
is VideoMessageType -> messageType.body
is LocationMessageType -> messageType.body
is OtherMessageType -> messageType.body
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt
index 2cb01ba2f7..04202bbb2f 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt
@@ -17,7 +17,6 @@
package io.element.android.libraries.push.impl.notifications
import android.Manifest
-import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.content.pm.PackageManager
@@ -32,12 +31,13 @@ class NotificationDisplayer @Inject constructor(
) {
private val notificationManager = NotificationManagerCompat.from(context)
- fun showNotificationMessage(tag: String?, id: Int, notification: Notification) {
+ fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Timber.w("Not allowed to notify.")
- return
+ return false
}
notificationManager.notify(tag, id, notification)
+ return true
}
fun cancelNotificationMessage(tag: String?, id: Int) {
@@ -53,15 +53,21 @@ class NotificationDisplayer @Inject constructor(
}
}
- @SuppressLint("LaunchActivityFromNotification")
- fun displayDiagnosticNotification(notification: Notification) {
- showNotificationMessage(
+ fun displayDiagnosticNotification(notification: Notification): Boolean {
+ return showNotificationMessage(
tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC,
notification = notification
)
}
+ fun dismissDiagnosticNotification() {
+ cancelNotificationMessage(
+ tag = "DIAGNOSTIC",
+ id = NOTIFICATION_ID_DIAGNOSTIC
+ )
+ }
+
/**
* Cancel the foreground notification service.
*/
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt
index 152ed0a03e..0bc3e69bfb 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt
@@ -19,9 +19,15 @@ package io.element.android.libraries.push.impl.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.push.impl.troubleshoot.NotificationClickHandler
+import javax.inject.Inject
class TestNotificationReceiver : BroadcastReceiver() {
+ @Inject lateinit var notificationClickHandler: NotificationClickHandler
+
override fun onReceive(context: Context, intent: Intent) {
- // TODO The test notification has been clicked, notify the ui
+ context.bindings().inject(this)
+ notificationClickHandler.handleNotificationClick()
}
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt
new file mode 100644
index 0000000000..6390bff885
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.notifications
+
+import com.squareup.anvil.annotations.ContributesTo
+import io.element.android.libraries.di.AppScope
+
+@ContributesTo(AppScope::class)
+interface TestNotificationReceiverBinding {
+ fun inject(service: TestNotificationReceiver)
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
index 2beddc53f9..67c8973d13 100755
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
@@ -299,6 +299,7 @@ class NotificationCreator @Inject constructor(
}
fun createDiagnosticNotification(): Notification {
+ val intent = pendingIntentFactory.createTestPendingIntent()
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
@@ -308,7 +309,8 @@ class NotificationCreator @Inject constructor(
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
- .setContentIntent(pendingIntentFactory.createTestPendingIntent())
+ .setContentIntent(intent)
+ .setDeleteIntent(intent)
.build()
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt
deleted file mode 100644
index 7496b3a16b..0000000000
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright (c) 2022 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.element.android.libraries.push.impl.permission
-
-import android.Manifest
-import android.app.Activity
-import android.content.Context
-import android.content.pm.PackageManager
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.core.content.ContextCompat
-import io.element.android.libraries.di.ApplicationContext
-import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
-import javax.inject.Inject
-
-// TODO EAx move
-class NotificationPermissionManager @Inject constructor(
- private val sdkIntProvider: BuildVersionSdkIntProvider,
- @ApplicationContext private val context: Context,
-) {
- @RequiresApi(Build.VERSION_CODES.TIRAMISU)
- fun isPermissionGranted(): Boolean {
- return ContextCompat.checkSelfPermission(
- context,
- Manifest.permission.POST_NOTIFICATIONS
- ) == PackageManager.PERMISSION_GRANTED
- }
-
- /*
- fun eventuallyRequestPermission(
- activity: Activity,
- requestPermissionLauncher: ActivityResultLauncher>,
- showRationale: Boolean = true,
- ignorePreference: Boolean = false,
- ) {
- if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
- // if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return
- checkPermissions(
- listOf(Manifest.permission.POST_NOTIFICATIONS),
- activity,
- activityResultLauncher = requestPermissionLauncher,
- if (showRationale) R.string.permissions_rationale_msg_notification else 0
- )
- }
- */
-
- fun eventuallyRevokePermission(
- activity: Activity,
- ) {
- if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
- activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS)
- }
-}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
index a930db2708..2c9abb77b3 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
@@ -27,6 +27,7 @@ import io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
+import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
@@ -51,6 +52,7 @@ class DefaultPushHandler @Inject constructor(
// private val actionIds: NotificationActionIds,
private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService,
+ private val diagnosticPushHandler: DiagnosticPushHandler,
) : PushHandler {
private val coroutineScope = CoroutineScope(SupervisorJob())
@@ -75,8 +77,7 @@ class DefaultPushHandler @Inject constructor(
// Diagnostic Push
if (pushData.eventId == PushersManager.TEST_EVENT_ID) {
- // val intent = Intent(actionIds.push)
- // TODO The test push has been received, notify the ui
+ diagnosticPushHandler.handlePush()
return
}
@@ -121,7 +122,7 @@ class DefaultPushHandler @Inject constructor(
return
}
- val userPushStore = userPushStoreFactory.create(userId)
+ val userPushStore = userPushStoreFactory.getOrCreate(userId)
if (!userPushStore.getNotificationEnabledForDevice().first()) {
// TODO We need to check if this is an incoming call
Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt
index 9e52d94049..5e341e3286 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt
@@ -23,6 +23,8 @@ import kotlinx.serialization.Serializable
internal data class PushGatewayNotification(
@SerialName("event_id")
val eventId: String,
+ @SerialName("room_id")
+ val roomId: String,
/**
* Required. This is an array of devices that the notification should be sent to.
*/
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt
index 7130e38d6e..e8c01493ab 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.push.impl.pushgateway
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.push.api.gateway.PushGatewayFailure
import javax.inject.Inject
@@ -27,7 +28,8 @@ class PushGatewayNotifyRequest @Inject constructor(
val url: String,
val appId: String,
val pushKey: String,
- val eventId: EventId
+ val eventId: EventId,
+ val roomId: RoomId,
)
suspend fun execute(params: Params) {
@@ -40,6 +42,7 @@ class PushGatewayNotifyRequest @Inject constructor(
PushGatewayNotifyBody(
PushGatewayNotification(
eventId = params.eventId.value,
+ roomId = params.roomId.value,
devices = listOf(
PushGatewayDevice(
params.appId,
@@ -51,7 +54,7 @@ class PushGatewayNotifyRequest @Inject constructor(
)
if (response.rejectedPushKeys.contains(params.pushKey)) {
- throw PushGatewayFailure.PusherRejected
+ throw PushGatewayFailure.PusherRejected()
}
}
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt
new file mode 100644
index 0000000000..3e7fe1ae59
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.troubleshoot
+
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.push.api.GetCurrentPushProvider
+import io.element.android.libraries.push.impl.R
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
+import io.element.android.services.toolbox.api.strings.StringProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+
+@ContributesMultibinding(AppScope::class)
+class CurrentPushProviderTest @Inject constructor(
+ private val getCurrentPushProvider: GetCurrentPushProvider,
+ private val stringProvider: StringProvider,
+) : NotificationTroubleshootTest {
+ override val order = 110
+ private val delegate = NotificationTroubleshootTestDelegate(
+ defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_title),
+ defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_description),
+ fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
+ )
+ override val state: StateFlow = delegate.state
+
+ override suspend fun run(coroutineScope: CoroutineScope) {
+ delegate.start()
+ val provider = getCurrentPushProvider.getCurrentPushProvider()
+ if (provider != null) {
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_success, provider),
+ status = NotificationTroubleshootTestState.Status.Success
+ )
+ } else {
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_failure),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ }
+ }
+
+ override suspend fun reset() = delegate.reset()
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt
new file mode 100644
index 0000000000..21b78161d9
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.troubleshoot
+
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+class DiagnosticPushHandler @Inject constructor() {
+ private val _state = MutableSharedFlow()
+ val state: SharedFlow = _state
+
+ suspend fun handlePush() {
+ _state.emit(Unit)
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt
new file mode 100644
index 0000000000..29f5fe0b9f
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.troubleshoot
+
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+class NotificationClickHandler @Inject constructor() {
+ private val _state = MutableSharedFlow(extraBufferCapacity = 1)
+ val state: SharedFlow = _state
+
+ fun handleNotificationClick() {
+ _state.tryEmit(Unit)
+ }
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt
new file mode 100644
index 0000000000..8de8304c2d
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.troubleshoot
+
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.push.impl.R
+import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
+import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
+import io.element.android.services.toolbox.api.strings.StringProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+
+@ContributesMultibinding(AppScope::class)
+class NotificationTest @Inject constructor(
+ private val notificationCreator: NotificationCreator,
+ private val notificationDisplayer: NotificationDisplayer,
+ private val notificationClickHandler: NotificationClickHandler,
+ private val stringProvider: StringProvider,
+) : NotificationTroubleshootTest {
+ override val order = 50
+ private val delegate = NotificationTroubleshootTestDelegate(
+ defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_title),
+ defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_description),
+ fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
+ )
+ override val state: StateFlow = delegate.state
+
+ override suspend fun run(coroutineScope: CoroutineScope) {
+ delegate.start()
+ val notification = notificationCreator.createDiagnosticNotification()
+ val result = notificationDisplayer.displayDiagnosticNotification(notification)
+ if (result) {
+ coroutineScope.listenToNotificationClick()
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_waiting),
+ status = NotificationTroubleshootTestState.Status.WaitingForUser
+ )
+ } else {
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_permission_failure),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ }
+ }
+
+ private fun CoroutineScope.listenToNotificationClick() = launch {
+ val job = launch {
+ notificationClickHandler.state.first()
+ Timber.d("Notification clicked!")
+ }
+ runCatching {
+ withTimeout(30.seconds) {
+ job.join()
+ }
+ }.fold(
+ onSuccess = {
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_success),
+ status = NotificationTroubleshootTestState.Status.Success
+ )
+ },
+ onFailure = {
+ job.cancel()
+ notificationDisplayer.dismissDiagnosticNotification()
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_failure),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ }
+ )
+ }.invokeOnCompletion {
+ // Ensure that the notification is cancelled when the screen is left
+ notificationDisplayer.dismissDiagnosticNotification()
+ }
+
+ override suspend fun reset() = delegate.reset()
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt
new file mode 100644
index 0000000000..42a6394fc9
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.troubleshoot
+
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.push.api.PushService
+import io.element.android.libraries.push.api.gateway.PushGatewayFailure
+import io.element.android.libraries.push.impl.R
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
+import io.element.android.services.toolbox.api.strings.StringProvider
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+
+@ContributesMultibinding(AppScope::class)
+class PushLoopbackTest @Inject constructor(
+ private val pushService: PushService,
+ private val diagnosticPushHandler: DiagnosticPushHandler,
+ private val clock: SystemClock,
+ private val stringProvider: StringProvider,
+) : NotificationTroubleshootTest {
+ override val order = 500
+ private val delegate = NotificationTroubleshootTestDelegate(
+ defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_title),
+ defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_description),
+ )
+ override val state: StateFlow = delegate.state
+
+ override suspend fun run(coroutineScope: CoroutineScope) {
+ delegate.start()
+ val startTime = clock.epochMillis()
+ val completable = CompletableDeferred()
+ val job = coroutineScope.launch {
+ diagnosticPushHandler.state.first()
+ completable.complete(clock.epochMillis() - startTime)
+ }
+ val testPushResult = try {
+ pushService.testPush()
+ } catch (pusherRejected: PushGatewayFailure.PusherRejected) {
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_1),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ job.cancel()
+ return
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to test push")
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_2, e.message),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ job.cancel()
+ return
+ }
+ if (!testPushResult) {
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_3),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ job.cancel()
+ return
+ }
+ runCatching {
+ withTimeout(10.seconds) {
+ completable.await()
+ }
+ }.fold(
+ onSuccess = { duration ->
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_success, duration),
+ status = NotificationTroubleshootTestState.Status.Success
+ )
+ },
+ onFailure = {
+ job.cancel()
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_4),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ }
+ )
+ }
+
+ override suspend fun reset() = delegate.reset()
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
new file mode 100644
index 0000000000..190cf8fe98
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.troubleshoot
+
+import com.squareup.anvil.annotations.ContributesMultibinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.push.impl.R
+import io.element.android.libraries.pushproviders.api.PushProvider
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate
+import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState
+import io.element.android.services.toolbox.api.strings.StringProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+
+@ContributesMultibinding(AppScope::class)
+class PushProvidersTest @Inject constructor(
+ pushProviders: Set<@JvmSuppressWildcards PushProvider>,
+ private val stringProvider: StringProvider,
+) : NotificationTroubleshootTest {
+ private val sortedPushProvider = pushProviders.sortedBy { it.index }
+ override val order = 100
+ private val delegate = NotificationTroubleshootTestDelegate(
+ defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_title),
+ defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_description),
+ fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY,
+ )
+ override val state: StateFlow = delegate.state
+
+ override suspend fun run(coroutineScope: CoroutineScope) {
+ delegate.start()
+ val result = sortedPushProvider.isNotEmpty()
+ if (result) {
+ delegate.updateState(
+ description = stringProvider.getQuantityString(
+ resId = R.plurals.troubleshoot_notifications_test_detect_push_provider_success,
+ quantity = sortedPushProvider.size,
+ sortedPushProvider.size,
+ sortedPushProvider.joinToString { it.name }
+ ),
+ status = NotificationTroubleshootTestState.Status.Success
+ )
+ } else {
+ delegate.updateState(
+ description = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_failure),
+ status = NotificationTroubleshootTestState.Status.Failure(false)
+ )
+ }
+ }
+
+ override suspend fun reset() = delegate.reset()
+}
diff --git a/libraries/push/impl/src/main/res/values-be/translations.xml b/libraries/push/impl/src/main/res/values-be/translations.xml
index 4c7ce25f88..686d10a281 100644
--- a/libraries/push/impl/src/main/res/values-be/translations.xml
+++ b/libraries/push/impl/src/main/res/values-be/translations.xml
@@ -6,12 +6,12 @@
"Ціхія апавяшчэнні"