Merge branch 'develop' into feature/fga/room_navigation

This commit is contained in:
ganfra
2024-04-10 16:55:55 +02:00
447 changed files with 3318 additions and 1374 deletions

View File

@@ -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

View File

@@ -6,4 +6,8 @@ appId: ${MAESTRO_APP_ID}
id: "verification-recovery_key"
- inputText: ${MAESTRO_RECOVERY_KEY}
- hideKeyboard
- tapOn: "Confirm"
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Device verified"
timeout: 10000
- tapOn: "Continue"

View File

@@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
visible: "Confirm that it's you"
timeout: 10000
timeout: 20000

View File

@@ -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)
========================================

View File

@@ -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)
}

View File

@@ -41,6 +41,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
@@ -58,6 +59,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack(
@@ -92,6 +94,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers()
trackVisitedRoom()
},
onResume = {
appCoroutineScope.launch {
@@ -110,6 +113,10 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
)
}
private fun trackVisitedRoom() = lifecycleScope.launch {
matrixClient.trackRecentlyVisitedRoom(inputs.room.roomId)
}
private fun fetchRoomMembers() = lifecycleScope.launch {
inputs.room.updateMembers()
}

View File

@@ -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

View File

@@ -1 +0,0 @@
Move session verification to the after login flow and make it mandatory.

View File

@@ -1 +0,0 @@
Add a notification troubleshoot screen

View File

@@ -1 +0,0 @@
Make completed poll more clearly visible

View File

@@ -1 +0,0 @@
Fix analytics issue around room considered as space by mistake.

View File

@@ -1 +0,0 @@
Fix crash observed when going back to the room list.

View File

@@ -1 +0,0 @@
Hide Event org.matrix.msc3401.call.member on the timeline.

View File

@@ -1 +0,0 @@
Add action to copy permalink

View File

@@ -0,0 +1,2 @@
Main changes in this version: Enable room moderation feature.
Full changelog: https://github.com/element-hq/element-x-android/releases

View File

@@ -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,
)
}
}

View File

@@ -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)
}

View File

@@ -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<UserListSta
override val values: Sequence<UserListState>
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<UserListSta
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
)
),
aUserListState(
recentDirectRooms = aRecentDirectRoomList(),
),
)
}

View File

@@ -16,10 +16,8 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@@ -64,21 +62,16 @@ fun AddPeopleView(
)
}
) { padding ->
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 = {},
)
}
}

View File

@@ -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()
}
}
}
}
}
}
}

View File

@@ -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<CreateRoomRoot
override val values: Sequence<CreateRoomRootState>
get() = sequenceOf(
aCreateRoomRootState(),
aCreateRoomRootState().copy(
aCreateRoomRootState(
startDmAction = AsyncAction.Loading,
userListState = aMatrixUser().let {
aUserListState().copy(
@@ -39,7 +42,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
)
}
),
aCreateRoomRootState().copy(
aCreateRoomRootState(
startDmAction = AsyncAction.Failure(Throwable("error")),
userListState = aMatrixUser().let {
aUserListState().copy(
@@ -50,12 +53,22 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
)
}
),
aCreateRoomRootState(
userListState = aUserListState(
recentDirectRooms = aRecentDirectRoomList()
)
),
)
}
fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {},
applicationName = "Element X Preview",
startDmAction = AsyncAction.Uninitialized,
userListState = aUserListState(),
fun aCreateRoomRootState(
applicationName: String = "Element X Preview",
userListState: UserListState = aUserListState(),
startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (CreateRoomRootEvents) -> Unit = {},
) = CreateRoomRootState(
applicationName = applicationName,
userListState = userListState,
startDmAction = startDmAction,
eventSink = eventSink,
)

View File

@@ -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,
)
}
}
}
}
}

View File

@@ -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<RecentDirectRoom>()) }
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

View File

@@ -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<MatrixUser>,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
val recentDirectRooms: ImmutableList<RecentDirectRoom>,
val eventSink: (UserListEvents) -> Unit,
) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple

View File

@@ -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<UserListState> {
override val values: Sequence<UserListState>
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<ImmutableList<UserSearchResult>> = SearchBarResultState.Initial(),
selectedUsers: List<MatrixUser> = emptyList(),
showSearchLoader: Boolean = false,
selectionMode: SelectionMode = SelectionMode.Single,
recentDirectRooms: List<RecentDirectRoom> = 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<RecentDirectRoom> = aMatrixUserList()
.take(count)
.map {
RecentDirectRoom(RoomId("!aRoom:id"), it)
}

View File

@@ -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<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
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<UserListEvents>()
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<UserListEvents>()
ensureCalledOnce {
rule.setAddPeopleView(
aUserListState(
eventSink = eventsRecorder,
),
onNextPressed = it
)
rule.clickOn(CommonStrings.action_skip)
}
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddPeopleView(
state: UserListState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
onNextPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AddPeopleView(
state = state,
onBackPressed = onBackPressed,
onNextPressed = onNextPressed,
)
}
}

View File

@@ -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<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(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<CreateRoomRootEvents>(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<CreateRoomRootEvents>(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<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnceWithParam(firstRoom.roomId) {
rule.setCreateRoomRootView(
aCreateRoomRootState(
userListState = aUserListState(
recentDirectRooms = recentDirectRoomList
),
eventSink = eventsRecorder,
),
onOpenDM = it
)
rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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,
)
}
}

View File

@@ -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()

View File

@@ -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()),
)
}

View File

@@ -19,11 +19,13 @@ 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
@@ -33,6 +35,7 @@ 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)
@@ -55,13 +58,27 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Parcelize
data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
}
interface Callback : Plugin {
fun onDone()
}
private val callback = plugins<Callback>().first()
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) {
@@ -72,8 +89,12 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
backstack.push(NavTarget.EnterRecoveryKey)
}
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() {
callback.onDone()
plugins<Callback>().forEach { it.onDone() }
}
})
.build()
@@ -81,11 +102,13 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
is NavTarget.EnterRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
.callback(object : SecureBackupEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
})
.callback(secureBackupEntryPointCallback)
.build()
}
is NavTarget.CreateNewRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
.callback(secureBackupEntryPointCallback)
.build()
}
}

View File

@@ -25,7 +25,6 @@ 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.matrix.api.verification.SessionVerifiedStatus
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
@@ -56,7 +55,7 @@ class DefaultFtueService @Inject constructor(
}
init {
sessionVerificationService.sessionVerifiedStatus
sessionVerificationService.needsVerificationFlow
.onEach { updateState() }
.launchIn(coroutineScope)
@@ -99,12 +98,8 @@ class DefaultFtueService @Inject constructor(
).any { it() }
}
private fun isSessionVerificationServiceReady(): Boolean {
return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
}
private fun isSessionNotVerified(): Boolean {
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified
return sessionVerificationService.needsVerificationFlow.value
}
private fun needsAnalyticsOptIn(): Boolean {
@@ -132,7 +127,6 @@ class DefaultFtueService @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() {
state.value = when {
!isSessionVerificationServiceReady() -> FtueState.Unknown
isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete
}

View File

@@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string>
<string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Адкрыйце Element на настольнай прыладзе"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Націсніце на свой аватар"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Выберыце %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Паказаць QR-код”"</string>
<string name="screen_qr_code_login_initial_state_title">"Адкрыйце Element на іншай прыладзе, каб атрымаць QR-код"</string>
<string name="screen_welcome_bullet_1">"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."</string>
<string name="screen_welcome_bullet_2">"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."</string>
<string name="screen_welcome_bullet_3">"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."</string>

View File

@@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Откройте Element на настольном устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Выбрать %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"\"Показать QR-код\""</string>
<string name="screen_qr_code_login_initial_state_title">"Откройте Element на другом устройстве, чтобы получить QR-код"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View File

@@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Open Element on a desktop device"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Link new device”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Select %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Show QR code”"</string>
<string name="screen_qr_code_login_initial_state_title">"Open Element on another device to get the QR code"</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View File

@@ -90,6 +90,7 @@ class DefaultFtueServiceTests {
fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
givenNeedsVerification(true)
}
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@@ -107,7 +108,7 @@ class DefaultFtueServiceTests {
// Session verification
steps.add(state.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
sessionVerificationService.givenNeedsVerification(false)
// Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))

View File

@@ -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<AggregatedReaction>,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,
)
}

View File

@@ -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()
}
}

View File

@@ -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<ComponentActivity>()
@Test
fun `dismiss the bottom sheet emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
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<RetrySendMenuEvents>()
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<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_remove)
eventsRecorder.assertSingle(RetrySendMenuEvents.Remove)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRetrySendMessageMenu(
state: RetrySendMenuState,
) {
setContent {
RetrySendMessageMenu(
state = state,
)
}
}

View File

@@ -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
@@ -212,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(

View File

@@ -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,
)
}

View File

@@ -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<RoomMemberListState> {
@@ -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) {

View File

@@ -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(

View File

@@ -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<Unit>) }
val canDisplayBannedUsers by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration) && !room.isDm && canBan()
value = !room.isDm && canBan()
}
fun handleEvent(event: RoomMembersModerationEvents) {

View File

@@ -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<Unit>>(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<UserId>, role: RoomMember.Role): Int {
return this?.userPowerLevels.orEmpty().count { (userId, level) ->
RoomMember.Role.forPowerLevel(level) == role && userId in joinedRoomMemberIds
}
}
}

View File

@@ -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

View File

@@ -65,7 +65,7 @@ class ChangeRolesNode @AssistedInject constructor(
ChangeRolesView(
modifier = modifier,
state = state,
onBackPressed = this::navigateUp,
navigateUp = this::navigateUp,
)
}
}

View File

@@ -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<String?>(null) }
var searchActive by rememberSaveable { mutableStateOf(false) }
var searchResults by remember {
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial())
mutableStateOf<SearchBarResultState<MembersByRole>>(SearchBarResultState.Initial())
}
val selectedUsers = remember {
mutableStateOf<ImmutableList<MatrixUser>>(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<RoomMember>.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<RoomMember>.sorted(): ImmutableList<RoomMember> {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
}
private fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
)
private fun CoroutineScope.save(
usersWithRole: ImmutableList<MatrixUser>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>,

View File

@@ -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<ImmutableList<RoomMember>>,
val searchResults: SearchBarResultState<MembersByRole>,
val selectedUsers: ImmutableList<MatrixUser>,
val hasPendingChanges: Boolean,
val exitState: AsyncAction<Unit>,
@@ -35,3 +37,21 @@ data class ChangeRolesState(
val canChangeMemberRole: (UserId) -> Boolean,
val eventSink: (ChangeRolesEvent) -> Unit,
)
data class MembersByRole(
val admins: ImmutableList<RoomMember>,
val moderators: ImmutableList<RoomMember>,
val members: ImmutableList<RoomMember>,
) {
constructor(members: List<RoomMember>) : 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<RoomMember>.sorted(): ImmutableList<RoomMember> {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
}

View File

@@ -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<ChangeRolesState> {
override val values: Sequence<ChangeRolesState>
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<ChangeRolesState> {
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<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(),
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
hasPendingChanges: Boolean = false,
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
savingState: AsyncAction<Unit> = 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") },
)

View File

@@ -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<RoomMember>,
currentRole: RoomMember.Role,
searchResults: MembersByRole,
selectedUsers: ImmutableList<MatrixUser>,
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<MatrixUser>,
) {
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,
)
}
)
}
}

View File

@@ -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))
}
}
}
}

View File

@@ -31,6 +31,7 @@
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Pending)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Pending)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Admins automatically have moderator privileges"</string>
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
<string name="screen_room_change_role_section_administrators">"Admins"</string>
<string name="screen_room_change_role_section_moderators">"Moderators"</string>
@@ -58,6 +59,7 @@
<string name="screen_room_details_room_name_label">"Room name"</string>
<string name="screen_room_details_security_title">"Security"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_details_title">"Room info"</string>
<string name="screen_room_details_topic_title">"Topic"</string>
<string name="screen_room_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_ban_member_confirmation_action">"Ban"</string>

View File

@@ -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
)

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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<ComponentActivity>()
@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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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<ChangeRolesEvent>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent(
state: ChangeRolesState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ChangeRolesView(
state = state,
navigateUp = onBackPressed,
)
}
}
}

View File

@@ -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()
}
}

View File

@@ -11,6 +11,8 @@
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"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"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"You dont have favourite chats yet"</string>
<string name="screen_roomlist_filter_invites">"Invites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"You don\'t have any pending invites."</string>
<string name="screen_roomlist_filter_low_priority">"Low Priority"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"You can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"You dont have chats for this selection"</string>

View File

@@ -31,6 +31,9 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
@Parcelize
data object EnterRecoveryKey : InitialTarget
@Parcelize
data object CreateNewRecoveryKey : InitialTarget
}
data class Params(val initialElement: InitialTarget) : NodeInputs
@@ -38,6 +41,7 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface Callback : Plugin {
fun onCreateNewRecoveryKey()
fun onDone()
}

View File

@@ -30,6 +30,7 @@ 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
@@ -50,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,
),
@@ -74,6 +76,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
@Parcelize
data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
}
private val callback = plugins<SecureBackupEntryPoint.Callback>().firstOrNull()
@@ -134,6 +139,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
}
createNode<SecureBackupEnterRecoveryKeyNode>(buildContext, plugins = listOf(callback))
}
NavTarget.CreateNewRecoveryKey -> {
createNode<CreateNewRecoveryKeyNode>(buildContext)
}
}
}

View File

@@ -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<Plugin>,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
CreateNewRecoveryKeyView(
modifier = modifier,
onBackClicked = ::navigateUp,
)
}
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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(),

View File

@@ -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,
)

View File

@@ -9,6 +9,13 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"Ваша рэзервовая копія чата зараз не сінхранізавана."</string>
<string name="screen_chat_backup_recovery_action_setup">"Наладзьце аднаўленне"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Атрымайце доступ да зашыфраваных паведамленняў, калі вы страціце ўсе свае прылады або выйдзеце з сістэмы %1$s усюды."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Адкрыйце Element на настольнай прыладзе"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Увайдзіце ў свой уліковы запіс яшчэ раз"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Калі будзе прапанавана пацвердзіць вашу прыладу, выберыце %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Скінуць усе”"</string>
<string name="screen_create_new_recovery_key_list_item_4">"Выконвайце інструкцыі, каб стварыць новы ключ аднаўлення"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Захавайце новы ключ аднаўлення ў ме́неджэры пароляў або ў зашыфраванай нататке"</string>
<string name="screen_create_new_recovery_key_title">"Скіньце шыфраванне для вашага ўліковага запісу з дапамогай іншай прылады"</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Адключыць"</string>
<string name="screen_key_backup_disable_confirmation_description">"Вы страціце зашыфраваныя паведамленні, калі выйдзеце з усіх прылад."</string>
<string name="screen_key_backup_disable_confirmation_title">"Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?"</string>
@@ -21,13 +28,15 @@
<string name="screen_recovery_key_change_generate_key_description">"Пераканайцеся, што вы можаце захаваць ключ аднаўлення ў бяспечным месцы"</string>
<string name="screen_recovery_key_change_success">"Ключ аднаўлення зменены"</string>
<string name="screen_recovery_key_change_title">"Змяніць ключ аднаўлення?"</string>
<string name="screen_recovery_key_confirm_description">"Увядзіце ключ аднаўлення, каб пацвердзіць доступ да рэзервовай копіі чата."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Стварыць новы ключ аднаўлення"</string>
<string name="screen_recovery_key_confirm_description">"Пераканайцеся, што ніхто не бачыць гэты экран!"</string>
<string name="screen_recovery_key_confirm_error_content">"Паўтарыце спробу, каб пацвердзіць доступ да рэзервовай копіі чата."</string>
<string name="screen_recovery_key_confirm_error_title">"Няправільны ключ аднаўлення"</string>
<string name="screen_recovery_key_confirm_key_description">"Увядзіце код з 48 сімвалаў."</string>
<string name="screen_recovery_key_confirm_key_description">"Калі ў вас ёсць ключ аднаўлення або парольная фраза, гэта таксама будзе працаваць."</string>
<string name="screen_recovery_key_confirm_key_label">"Ключ аднаўлення або код доступу"</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Увесці…"</string>
<string name="screen_recovery_key_confirm_success">"Ключ аднаўлення пацверджаны"</string>
<string name="screen_recovery_key_confirm_title">"Пацвердзіце ключ аднаўлення"</string>
<string name="screen_recovery_key_confirm_title">"Увядзіце ключ аднаўлення або код доступу"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Ключ аднаўлення скапіраваны"</string>
<string name="screen_recovery_key_generating_key">"Стварэнне…"</string>
<string name="screen_recovery_key_save_action">"Захаваць ключ аднаўлення"</string>

View File

@@ -9,6 +9,13 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"Vaše záloha chatu není aktuálně synchronizována."</string>
<string name="screen_chat_backup_recovery_action_setup">"Nastavení obnovy"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"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."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Otevřít Element na stolním počítači"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Znovu se přihlaste ke svému účtu"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Když budete vyzváni k ověření vašeho zařízení, vyberte %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"\"Resetovat vše\""</string>
<string name="screen_create_new_recovery_key_list_item_4">"Postupujte podle pokynů k vytvoření nového obnovovacího klíče"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Uložte nový klíč pro obnovení do správce hesel nebo do zašifrované poznámky"</string>
<string name="screen_create_new_recovery_key_title">"Obnovte šifrování účtu pomocí jiného zařízení"</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Vypnout"</string>
<string name="screen_key_backup_disable_confirmation_description">"Pokud se odhlásíte ze všech zařízení, přijdete o zašifrované zprávy."</string>
<string name="screen_key_backup_disable_confirmation_title">"Opravdu chcete vypnout zálohování?"</string>
@@ -21,10 +28,12 @@
<string name="screen_recovery_key_change_generate_key_description">"Ujistěte se, že můžete klíč pro obnovení uložit někde v bezpečí"</string>
<string name="screen_recovery_key_change_success">"Klíč pro obnovení byl změněn"</string>
<string name="screen_recovery_key_change_title">"Změnit klíč pro obnovení?"</string>
<string name="screen_recovery_key_confirm_description">"Zadejte klíč pro obnovení a potvrďte přístup k záloze chatu."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Vytvořit nový klíč pro obnovení"</string>
<string name="screen_recovery_key_confirm_description">"Ujistěte se, že tuto obrazovku nikdo nevidí!"</string>
<string name="screen_recovery_key_confirm_error_content">"Zkuste prosím znovu potvrdit přístup k záloze chatu."</string>
<string name="screen_recovery_key_confirm_error_title">"Nesprávný klíč pro obnovení"</string>
<string name="screen_recovery_key_confirm_key_description">"Zadejte kód o délce 48 znaků."</string>
<string name="screen_recovery_key_confirm_key_description">"Pokud máte bezpečnostní klíč nebo bezpečnostní frázi, bude to fungovat také."</string>
<string name="screen_recovery_key_confirm_key_label">"Klíč pro obnovení nebo přístupový kód"</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Zadejte…"</string>
<string name="screen_recovery_key_confirm_success">"Klíč pro obnovení potvrzen"</string>
<string name="screen_recovery_key_confirm_title">"Potvrďte klíč pro obnovení"</string>

View File

@@ -9,6 +9,26 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"Dein Chat-Backup ist derzeit nicht synchronisiert."</string>
<string name="screen_chat_backup_recovery_action_setup">"Wiederherstellung einrichten"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Erhalte Zugriff auf deine verschlüsselten Nachrichten, wenn du alle deine Geräte verlierst oder von %1$s überall abgemeldet bist."</string>
<string name="screen_create_new_recovery_key_list_item_1">
"Öffne "
<b>"Element"</b>
" auf einem "
<b>"Desktop-Gerät"</b>
</string>
<string name="screen_create_new_recovery_key_list_item_2">"Melde dich erneut bei deinem Konto an"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Wenn du aufgefordert wirst dein Gerät zu verifizieren, wähle \"%1$s\"."</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"Alles zurücksetzen"</string>
<string name="screen_create_new_recovery_key_list_item_4">"Folge den Anweisungen, um einen neuen Wiederherstellungsschlüssel zu erstellen"</string>
<string name="screen_create_new_recovery_key_list_item_5">
"Speichere deinen neuen "
<b>"Wiederherstellungsschlüssel"</b>
" in einem Passwortmanager oder einer verschlüsselten Notiz"
</string>
<string name="screen_create_new_recovery_key_title">
"Erstelle einen neuen "
<b>"Wiederherstellungsschlüssel"</b>
" mit einem anderen Gerät"
</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Ausschalten"</string>
<string name="screen_key_backup_disable_confirmation_description">"Du verlierst deine verschlüsselten Nachrichten, wenn du auf allen Geräten abgemeldet bist."</string>
<string name="screen_key_backup_disable_confirmation_title">"Bist du sicher, dass du das Backup ausschalten willst?"</string>
@@ -21,13 +41,22 @@
<string name="screen_recovery_key_change_generate_key_description">"Stelle sicher, dass du deinen Wiederherstellungsschlüssel an einem sicheren Ort aufbewahren kannst"</string>
<string name="screen_recovery_key_change_success">"Wiederherstellungsschlüssel geändert"</string>
<string name="screen_recovery_key_change_title">"Wiederherstellungsschlüssel ändern?"</string>
<string name="screen_recovery_key_confirm_description">"Gib deinen Wiederherstellungsschlüssel ein, um den Zugriff auf dein Chat-Backup zu bestätigen."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">
"Neuen "
<b>"Wiederherstellungsschlüssel"</b>
" erstellen"
</string>
<string name="screen_recovery_key_confirm_description">"Sorge dafür, dass niemand diesen Bildschirm sehen kann!"</string>
<string name="screen_recovery_key_confirm_error_content">"Bitte versuche es noch einmal, um den Zugriff auf dein Chat-Backup zu bestätigen."</string>
<string name="screen_recovery_key_confirm_error_title">"Falscher Wiederherstellungsschlüssel"</string>
<string name="screen_recovery_key_confirm_key_description">"Gib den 48-stelligen Code ein."</string>
<string name="screen_recovery_key_confirm_key_description">"Dies funktioniert auch mit einem Sicherheitsschlüssel oder Sicherheitsphrase."</string>
<string name="screen_recovery_key_confirm_key_label">
<b>"Wiederherstellungsschlüssel"</b>
" oder Passcode"
</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Eingeben…"</string>
<string name="screen_recovery_key_confirm_success">"Wiederherstellungsschlüssel bestätigt"</string>
<string name="screen_recovery_key_confirm_title">"Wiederherstellungsschlüssel bestätigen."</string>
<string name="screen_recovery_key_confirm_title">"Wiederherstellungsschlüssel oder Passcode bestätigen"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Wiederherstellungsschlüssel kopiert"</string>
<string name="screen_recovery_key_generating_key">"Generieren…"</string>
<string name="screen_recovery_key_save_action">"Wiederherstellungsschlüssel speichern"</string>

View File

@@ -9,6 +9,13 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"A csevegéselőzményei nincsenek szinkronban."</string>
<string name="screen_chat_backup_recovery_action_setup">"Helyreállítás beállítása"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"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."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Nyissa meg az Elementet egy asztali eszközön"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Jelentkezzen be újra a fiókjába"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Amikor az eszköz ellenőrzését kéri, válassza ezt a lehetőséget: %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"„Minden visszaállítása”"</string>
<string name="screen_create_new_recovery_key_list_item_4">"Kövesse az utasításokat egy új helyreállítási kulcs létrehozásához"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Mentse az új helyreállítási kulcsot egy jelszókezelőbe vagy egy titkosított jegyzetbe."</string>
<string name="screen_create_new_recovery_key_title">"A fiók titkosításának visszaállítása egy másik eszköz használatával"</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Kikapcsolás"</string>
<string name="screen_key_backup_disable_confirmation_description">"Ha kijelentkezik az összes eszközéről, akkor elveszti a titkosított üzeneteit."</string>
<string name="screen_key_backup_disable_confirmation_title">"Biztos, hogy kikapcsolja a biztonsági mentéseket?"</string>
@@ -21,13 +28,15 @@
<string name="screen_recovery_key_change_generate_key_description">"Gondoskodjon arról, hogy biztonságos helyen tárolja a helyreállítási kulcsát"</string>
<string name="screen_recovery_key_change_success">"Helyreállítási kulcs lecserélve"</string>
<string name="screen_recovery_key_change_title">"Módosítja a helyreállítási kulcsot?"</string>
<string name="screen_recovery_key_confirm_description">"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."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Új helyreállítási kulcs létrehozása"</string>
<string name="screen_recovery_key_confirm_description">"Győződjön meg arról, hogy senki sem látja ezt a képernyőt!"</string>
<string name="screen_recovery_key_confirm_error_content">"Próbálja meg újra megerősíteni a csevegés biztonsági mentéséhez való hozzáférését."</string>
<string name="screen_recovery_key_confirm_error_title">"Helytelen helyreállítási kulcs"</string>
<string name="screen_recovery_key_confirm_key_description">"Adja meg a 48 karakteres kódot."</string>
<string name="screen_recovery_key_confirm_key_description">"Ha van helyreállítási kulcsa vagy titkos jelmondata/kulcsa, akkor ez is fog működni."</string>
<string name="screen_recovery_key_confirm_key_label">"Helyreállítási kulcs vagy jelkód"</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Megadás…"</string>
<string name="screen_recovery_key_confirm_success">"Helyreállítási kulcs megerősítve"</string>
<string name="screen_recovery_key_confirm_title">"Erősítse meg a helyreállítási kulcsát"</string>
<string name="screen_recovery_key_confirm_title">"Adja meg a helyreállítási kulcsát vagy a jelkódját"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Helyreállítási kulcs másolva"</string>
<string name="screen_recovery_key_generating_key">"Előállítás…"</string>
<string name="screen_recovery_key_save_action">"Helyreállítási kulcs mentése"</string>

View File

@@ -9,6 +9,13 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"Pencadangan percakapan Anda saat ini tidak tersinkron."</string>
<string name="screen_chat_backup_recovery_action_setup">"Siapkan pemulihan"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Dapatkan akses ke pesan terenkripsi Anda jika Anda kehilangan semua perangkat Anda atau keluar dari %1$s di mana pun."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Buka Element di perangkat desktop"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Masuk ke akun Anda lagi"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Saat diminta untuk memverifikasi perangkat Anda, pilih %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Atur ulang semua”"</string>
<string name="screen_create_new_recovery_key_list_item_4">"Ikuti petunjuk untuk membuat kunci pemulihan baru"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Simpan kunci pemulihan baru Anda dalam pengelola kata sandi atau catatan terenkripsi"</string>
<string name="screen_create_new_recovery_key_title">"Atur ulang enkripsi untuk akun Anda menggunakan perangkat lain"</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Matikan"</string>
<string name="screen_key_backup_disable_confirmation_description">"Anda akan kehilangan pesan terenkripsi jika Anda keluar dari semua perangkat."</string>
<string name="screen_key_backup_disable_confirmation_title">"Apakah Anda yakin ingin mematikan pencadangan?"</string>
@@ -21,13 +28,15 @@
<string name="screen_recovery_key_change_generate_key_description">"Pastikan Anda dapat menyimpan kunci pemulihan Anda di tempat yang aman"</string>
<string name="screen_recovery_key_change_success">"Kunci pemulihan diganti"</string>
<string name="screen_recovery_key_change_title">"Ubah kunci pemulihan?"</string>
<string name="screen_recovery_key_confirm_description">"Masukkan kunci pemulihan Anda untuk mengonfirmasi akses ke cadangan percakapan Anda."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Buat kunci pemulihan baru"</string>
<string name="screen_recovery_key_confirm_description">"Pastikan tidak ada yang bisa melihat layar ini!"</string>
<string name="screen_recovery_key_confirm_error_content">"Silakan coba lagi untuk mengonfirmasi akses ke cadangan percakapan Anda."</string>
<string name="screen_recovery_key_confirm_error_title">"Kunci pemulihan salah"</string>
<string name="screen_recovery_key_confirm_key_description">"Masukkan kode 48 karakter."</string>
<string name="screen_recovery_key_confirm_key_description">"Jika Anda memiliki frasa sandi pemulihan atau frasa/kunci sandi rahasia, ini juga dapat digunakan."</string>
<string name="screen_recovery_key_confirm_key_label">"Kunci pemulihan atau kode sandi"</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Masukkan…"</string>
<string name="screen_recovery_key_confirm_success">"Kunci pemulihan dikonfirmasi"</string>
<string name="screen_recovery_key_confirm_title">"Konfirmasi kunci pemulihan Anda"</string>
<string name="screen_recovery_key_confirm_title">"Konfirmasi kunci pemulihan atau kode sandi Anda"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Kunci pemulihan disalin"</string>
<string name="screen_recovery_key_generating_key">"Membuat…"</string>
<string name="screen_recovery_key_save_action">"Simpan kunci pemulihan"</string>

View File

@@ -4,11 +4,28 @@
<string name="screen_chat_backup_key_backup_action_enable">"Включить резервное копирование"</string>
<string name="screen_chat_backup_key_backup_description">"Резервное копирование гарантирует, что вы не потеряете историю сообщений. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Резервное копирование"</string>
<string name="screen_chat_backup_recovery_action_change">"Изменить ключ восстановления"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Подтвердить ключ восстановления"</string>
<string name="screen_chat_backup_recovery_action_change">
"Изменить "
<b>"ключ восстановления"</b>
</string>
<string name="screen_chat_backup_recovery_action_confirm">
"Ввести "
<b>"ключ восстановления"</b>
</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Резервная копия чата в настоящее время не синхронизирована."</string>
<string name="screen_chat_backup_recovery_action_setup">"Настроить восстановление"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Получите доступ к зашифрованным сообщениям, если вы потеряете все свои устройства или выйдете из системы %1$s отовсюду."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Откройте Element на настольном устройстве"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Войдите в свой аккаунт еще раз"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Когда вас попросят подтвердить устройство, выберите %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Сбросить все”"</string>
<string name="screen_create_new_recovery_key_list_item_4">"Следуйте инструкциям, чтобы создать новый ключ восстановления"</string>
<string name="screen_create_new_recovery_key_list_item_5">
"Сохраните новый "
<b>"ключ восстановления"</b>
" в менеджере паролей или зашифрованной заметке"
</string>
<string name="screen_create_new_recovery_key_title">"Сбросьте шифрование вашей учетной записи с помощью другого устройства."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Выключить"</string>
<string name="screen_key_backup_disable_confirmation_description">"Вы потеряете зашифрованные сообщения, если выйдете из всех устройств."</string>
<string name="screen_key_backup_disable_confirmation_title">"Вы действительно хотите отключить резервное копирование?"</string>
@@ -17,27 +34,62 @@
<string name="screen_key_backup_disable_description_point_2">"Потерять доступ к зашифрованным сообщениям, если вы вышли из %1$s любой точки мира"</string>
<string name="screen_key_backup_disable_title">"Вы действительно хотите отключить резервное копирование?"</string>
<string name="screen_recovery_key_change_description">"Получите новый ключ восстановления, если вы потеряли существующий. После смены ключа восстановления старый ключ больше не будет работать."</string>
<string name="screen_recovery_key_change_generate_key">"Создать новый ключ восстановления"</string>
<string name="screen_recovery_key_change_generate_key">
"Создать новый "
<b>"ключ восстановления"</b>
</string>
<string name="screen_recovery_key_change_generate_key_description">"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"</string>
<string name="screen_recovery_key_change_success">"Ключ восстановления изменен"</string>
<string name="screen_recovery_key_change_success">
<b>"Ключ восстановления"</b>
" изменен"
</string>
<string name="screen_recovery_key_change_title">"Изменить ключ восстановления?"</string>
<string name="screen_recovery_key_confirm_description">"Введите ключ восстановления, чтобы подтвердить доступ к резервной копии чата."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">
"Создать новый "
<b>"ключ восстановления"</b>
</string>
<string name="screen_recovery_key_confirm_description">"Убедитесь, что никто не видит этот экран!"</string>
<string name="screen_recovery_key_confirm_error_content">"Пожалуйста, попробуйте еще раз, чтобы подтвердить доступ к резервной копии чата."</string>
<string name="screen_recovery_key_confirm_error_title">"Неверный ключ восстановления"</string>
<string name="screen_recovery_key_confirm_key_description">"Введите 48 значный код."</string>
<string name="screen_recovery_key_confirm_error_title">
"Неверный "
<b>"ключ восстановления"</b>
</string>
<string name="screen_recovery_key_confirm_key_description">"Если у вас есть пароль для восстановления или секретный пароль/ключ, это тоже сработает."</string>
<string name="screen_recovery_key_confirm_key_label">
<b>"Ключ восстановления"</b>
" или пароль"
</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Вход…"</string>
<string name="screen_recovery_key_confirm_success">"Ключ восстановления подтвержден"</string>
<string name="screen_recovery_key_confirm_title">"Подтвердите ключ восстановления"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Ключ восстановления скопирован"</string>
<string name="screen_recovery_key_confirm_success">
<b>"Ключ восстановления"</b>
" подтвержден"
</string>
<string name="screen_recovery_key_confirm_title">
"Подтвердите "
<b>"ключ восстановления"</b>
</string>
<string name="screen_recovery_key_copied_to_clipboard">
<b>"Ключ восстановления"</b>
" скопирован"
</string>
<string name="screen_recovery_key_generating_key">"Генерация…"</string>
<string name="screen_recovery_key_save_action">"Сохранить ключ восстановления"</string>
<string name="screen_recovery_key_save_action">
"Сохранить "
<b>"ключ восстановления"</b>
</string>
<string name="screen_recovery_key_save_description">"Запишите ключ восстановления в безопасном месте или сохраните его в менеджере паролей."</string>
<string name="screen_recovery_key_save_key_description">"Нажмите, чтобы скопировать ключ восстановления"</string>
<string name="screen_recovery_key_save_title">"Сохраните ключ восстановления"</string>
<string name="screen_recovery_key_save_title">
"Сохраните "
<b>"ключ восстановления"</b>
</string>
<string name="screen_recovery_key_setup_confirmation_description">"После этого шага вы не сможете получить доступ к новому ключу восстановления."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Вы сохранили ключ восстановления?"</string>
<string name="screen_recovery_key_setup_description">"Резервная копия чата защищена ключом восстановления. Если после настройки вам понадобится новый ключ восстановления, вы можете создать его заново, выбрав «Изменить ключ восстановления»."</string>
<string name="screen_recovery_key_setup_generate_key">"Сгенерируйте свой ключ восстановления"</string>
<string name="screen_recovery_key_setup_generate_key">
"Создайте "
<b>"ключ восстановления"</b>
</string>
<string name="screen_recovery_key_setup_generate_key_description">"Убедитесь, что вы можете хранить ключ восстановления в безопасном месте"</string>
<string name="screen_recovery_key_setup_success">"Настройка восстановления выполнена успешно"</string>
<string name="screen_recovery_key_setup_title">"Настроить восстановление"</string>

View File

@@ -9,6 +9,13 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"Vaša záloha konverzácie nie je momentálne synchronizovaná."</string>
<string name="screen_chat_backup_recovery_action_setup">"Nastaviť obnovovanie"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"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í."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Otvoriť Element v stolnom počítači"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Znova sa prihláste do svojho účtu"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Keď sa zobrazí výzva na overenie vášho zariadenia, vyberte %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"\"Obnoviť všetko\""</string>
<string name="screen_create_new_recovery_key_list_item_4">"Postupujte podľa pokynov na vytvorenie nového kľúča na obnovenie"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Uložte si nový kľúč na obnovenie do správcu hesiel alebo do zašifrovanej poznámky"</string>
<string name="screen_create_new_recovery_key_title">"Obnovte šifrovanie vášho účtu pomocou iného zariadenia"</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Vypnúť"</string>
<string name="screen_key_backup_disable_confirmation_description">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení"</string>
<string name="screen_key_backup_disable_confirmation_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
@@ -21,13 +28,15 @@
<string name="screen_recovery_key_change_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
<string name="screen_recovery_key_change_success">"Kľúč na obnovenie bol zmenený"</string>
<string name="screen_recovery_key_change_title">"Zmeniť kľúč na obnovenie?"</string>
<string name="screen_recovery_key_confirm_description">"Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Vytvoriť nový kľúč na obnovenie"</string>
<string name="screen_recovery_key_confirm_description">"Uistite sa, že túto obrazovku nikto nevidí!"</string>
<string name="screen_recovery_key_confirm_error_content">"Skúste prosím znova potvrdiť prístup k vašej zálohe konverzácie."</string>
<string name="screen_recovery_key_confirm_error_title">"Nesprávny kľúč na obnovenie"</string>
<string name="screen_recovery_key_confirm_key_description">"Zadajte 48-znakový kód."</string>
<string name="screen_recovery_key_confirm_key_description">"Ak máte frázu na obnovenie alebo tajné heslo/kľúč, bude to tiež fungovať."</string>
<string name="screen_recovery_key_confirm_key_label">"Kľúč na obnovenie alebo prístupový kód"</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Zadať…"</string>
<string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</string>
<string name="screen_recovery_key_confirm_title">"Potvrďte kľúč na obnovenie"</string>
<string name="screen_recovery_key_confirm_title">"Zadajte kľúč na obnovenie alebo prístupový kód"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Skopírovaný kľúč na obnovenie"</string>
<string name="screen_recovery_key_generating_key">"Generovanie…"</string>
<string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string>

View File

@@ -9,6 +9,13 @@
<string name="screen_chat_backup_recovery_action_confirm_description">"Your chat backup is currently out of sync."</string>
<string name="screen_chat_backup_recovery_action_setup">"Set up recovery"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Open Element in a desktop device"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Sign into your account again"</string>
<string name="screen_create_new_recovery_key_list_item_3">"When asked to verify your device, select %1$s"</string>
<string name="screen_create_new_recovery_key_list_item_3_reset_all">"“Reset all”"</string>
<string name="screen_create_new_recovery_key_list_item_4">"Follow the instructions to create a new recovery key"</string>
<string name="screen_create_new_recovery_key_list_item_5">"Save your new recovery key in a password manager or encrypted note"</string>
<string name="screen_create_new_recovery_key_title">"Reset the encryption for your account using another device"</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
@@ -21,13 +28,15 @@
<string name="screen_recovery_key_change_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
<string name="screen_recovery_key_change_success">"Recovery key changed"</string>
<string name="screen_recovery_key_change_title">"Change recovery key?"</string>
<string name="screen_recovery_key_confirm_description">"Enter your recovery key to confirm access to your chat backup."</string>
<string name="screen_recovery_key_confirm_create_new_recovery_key">"Create new recovery key"</string>
<string name="screen_recovery_key_confirm_description">"Make sure nobody can see this screen!"</string>
<string name="screen_recovery_key_confirm_error_content">"Please try again to confirm access to your chat backup."</string>
<string name="screen_recovery_key_confirm_error_title">"Incorrect recovery key"</string>
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string>
<string name="screen_recovery_key_confirm_key_description">"If you have a security key or security phrase, this will work too."</string>
<string name="screen_recovery_key_confirm_key_label">"Recovery key or passcode"</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
<string name="screen_recovery_key_confirm_title">"Enter your recovery key"</string>
<string name="screen_recovery_key_confirm_title">"Enter your recovery key or passcode"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Copied recovery key"</string>
<string name="screen_recovery_key_generating_key">"Generating…"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string>

View File

@@ -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,
)
}

View File

@@ -31,6 +31,7 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onEnterRecoveryKey()
fun onCreateNewRecoveryKey()
fun onDone()
}
}

View File

@@ -34,17 +34,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val presenter: VerifySelfSessionPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onEnterRecoveryKey() {
plugins<VerifySessionEntryPoint.Callback>().forEach {
it.onEnterRecoveryKey()
}
}
private fun onDone() {
plugins<VerifySessionEntryPoint.Callback>().forEach {
it.onDone()
}
}
private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
@Composable
override fun View(modifier: Modifier) {
@@ -52,8 +42,9 @@ class VerifySelfSessionNode @AssistedInject constructor(
VerifySelfSessionView(
state = state,
modifier = modifier,
onEnterRecoveryKey = ::onEnterRecoveryKey,
onFinished = ::onDone,
onEnterRecoveryKey = callback::onEnterRecoveryKey,
onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey,
onFinished = callback::onDone,
)
}
}

View File

@@ -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<VerifySelfSessionState> {
@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
@@ -72,10 +85,15 @@ class VerifySelfSessionPresenter @Inject constructor(
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
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,

View File

@@ -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<Unit>) : VerificationStep
data object Completed : VerificationStep
data object Skipped : VerificationStep
}
}

View File

@@ -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<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
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,
)

View File

@@ -28,8 +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.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.painterResource
@@ -51,21 +55,30 @@ 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.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,
onCreateNewRecoveryKey: () -> Unit,
onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
fun resetFlow() {
state.eventSink(VerifySelfSessionViewEvents.Reset)
}
val updatedOnFinished by rememberUpdatedState(newValue = onFinished)
LaunchedEffect(state.verificationFlowStep, updatedOnFinished) {
if (state.verificationFlowStep is FlowStep.Skipped) {
updatedOnFinished()
}
}
BackHandler {
when (state.verificationFlowStep) {
is FlowStep.Canceled -> resetFlow()
@@ -81,6 +94,19 @@ fun VerifySelfSessionView(
val verificationFlowStep = state.verificationFlowStep
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)
},
@@ -89,6 +115,7 @@ fun VerifySelfSessionView(
screenState = state,
goBack = ::resetFlow,
onEnterRecoveryKey = onEnterRecoveryKey,
onCreateNewRecoveryKey = onCreateNewRecoveryKey,
onFinished = onFinished,
)
}
@@ -104,6 +131,7 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
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, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title
@@ -114,20 +142,21 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
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, FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle
FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle
FlowStep.Ready -> R.string.screen_session_verification_ready_subtitle
FlowStep.Completed -> R.string.screen_identity_confirmation_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
}
PageTitle(
modifier = Modifier.padding(top = 60.dp),
iconStyle = iconStyle,
title = stringResource(id = titleTextId),
subtitle = stringResource(id = subtitleTextId)
@@ -137,9 +166,8 @@ private fun HeaderContent(verificationFlowStep: FlowStep) {
@Composable
private fun Content(flowState: FlowStep) {
Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) {
when (flowState) {
is FlowStep.Initial, FlowStep.AwaitingOtherDeviceResponse, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit
is FlowStep.Verifying -> ContentVerifying(flowState)
if (flowState is FlowStep.Verifying) {
ContentVerifying(flowState)
}
}
}
@@ -200,6 +228,7 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
private fun BottomMenu(
screenState: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit,
onCreateNewRecoveryKey: () -> Unit,
goBack: () -> Unit,
onFinished: () -> Unit,
) {
@@ -210,12 +239,21 @@ private fun BottomMenu(
when (verificationViewState) {
is FlowStep.Initial -> {
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,
)
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 {
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,
)
}
}
is FlowStep.Canceled -> {
BottomMenu(
@@ -264,6 +302,7 @@ private fun BottomMenu(
onPositiveButtonClicked = onFinished,
)
}
is FlowStep.Skipped -> return
}
}
@@ -307,6 +346,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = {},
onCreateNewRecoveryKey = {},
onFinished = {},
)
}

View File

@@ -23,4 +23,5 @@ sealed interface VerifySelfSessionViewEvents {
data object DeclineVerification : VerifySelfSessionViewEvents
data object Cancel : VerifySelfSessionViewEvents
data object Reset : VerifySelfSessionViewEvents
data object SkipVerification : VerifySelfSessionViewEvents
}

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Стварыць новы ключ аднаўлення"</string>
<string name="screen_identity_confirmation_subtitle">"Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі."</string>
<string name="screen_identity_confirmation_title">"Пацвердзіце, што гэта вы"</string>
<string name="screen_identity_confirmed_subtitle">"Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе."</string>
<string name="screen_identity_confirmed_title">"Прылада праверана"</string>
<string name="screen_identity_use_another_device">"Выкарыстоўвайце іншую прыладу"</string>
<string name="screen_identity_waiting_on_other_device">"Чаканне на іншай прыладзе…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Здаецца, нешта не так. Альбо час чакання запыту скончыўся, альбо запыт быў адхілены."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Пераканайцеся, што прыведзеныя ніжэй эмодзі супадаюць з эмодзі, паказанымі ў вашым іншым сеансе."</string>
<string name="screen_session_verification_compare_emojis_title">"Параўнайце эмодзі"</string>

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Vytvoření nového klíče pro obnovení"</string>
<string name="screen_identity_confirmation_subtitle">"Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv."</string>
<string name="screen_identity_confirmation_title">"Potvrďte, že jste to vy"</string>
<string name="screen_identity_confirmed_subtitle">"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."</string>
<string name="screen_identity_confirmed_title">"Zařízení ověřeno"</string>
<string name="screen_identity_use_another_device">"Použít jiné zařízení"</string>
<string name="screen_identity_waiting_on_other_device">"Čekání na jiném zařízení…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Něco není v pořádku. Buď vypršel časový limit požadavku, nebo byl požadavek zamítnut."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Zkontrolujte, zda se níže uvedené emotikony shodují s emotikony zobrazenými na jiné relaci."</string>
<string name="screen_session_verification_compare_emojis_title">"Porovnání emotikonů"</string>

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Erstelle einen neuen Wiederherstellungsschlüssel"</string>
<string name="screen_identity_confirmation_subtitle">"Verifiziere dieses Gerät, um sicheres Messaging einzurichten."</string>
<string name="screen_identity_confirmation_title">"Bestätige, dass du es bist"</string>
<string name="screen_identity_confirmed_subtitle">"Du kannst nun verschlüsselte Nachrichten lesen oder versenden."</string>
<string name="screen_identity_confirmed_title">"Gerät verifiziert"</string>
<string name="screen_identity_use_another_device">"Ein anderes Gerät verwenden"</string>
<string name="screen_identity_waiting_on_other_device">"Bitte warten bis das andere Gerät bereit ist."</string>
<string name="screen_session_verification_cancelled_subtitle">"Etwas scheint nicht zu stimmen. Entweder ist das Zeitlimit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Vergewissere dich dass die folgenden Emojis mit denen in deiner anderen Session übereinstimmen."</string>
<string name="screen_session_verification_compare_emojis_title">"Emojis vergleichen"</string>

View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_subtitle">"Vérifier cette session pour configurer votre messagerie sécurisée."</string>
<string name="screen_identity_confirmation_title">"Confirmez votre identité"</string>
<string name="screen_identity_confirmed_subtitle">"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."</string>
<string name="screen_identity_confirmed_title">"Session vérifiée"</string>
<string name="screen_identity_use_another_device">"Utiliser une autre session"</string>
<string name="screen_identity_waiting_on_other_device">"En attente dune autre session…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Quelque chose ne va pas. Soit la demande a expiré, soit elle a été refusée."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Confirmez que les emojis ci-dessous correspondent à ceux affichés sur votre autre session."</string>
<string name="screen_session_verification_compare_emojis_title">"Comparez les émojis"</string>

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Új helyreállítási kulcs létrehozása"</string>
<string name="screen_identity_confirmation_subtitle">"A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt."</string>
<string name="screen_identity_confirmation_title">"Erősítse meg, hogy Ön az"</string>
<string name="screen_identity_confirmed_subtitle">"Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben."</string>
<string name="screen_identity_confirmed_title">"Eszköz ellenőrizve"</string>
<string name="screen_identity_use_another_device">"Másik eszköz használata"</string>
<string name="screen_identity_waiting_on_other_device">"Várakozás a másik eszközre…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Valami hibásnak tűnik. A kérés vagy időtúllépésre futott, vagy elutasították."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Erősítse meg, hogy a lenti emodzsik egyeznek-e a másik munkamenetben megjelenítettekkel."</string>
<string name="screen_session_verification_compare_emojis_title">"Emodzsik összehasonlítása"</string>

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Buat kunci pemulihan baru"</string>
<string name="screen_identity_confirmation_subtitle">"Verifikasi perangkat ini untuk menyiapkan perpesanan aman."</string>
<string name="screen_identity_confirmation_title">"Konfirmasi bahwa ini Anda"</string>
<string name="screen_identity_confirmed_subtitle">"Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini."</string>
<string name="screen_identity_confirmed_title">"Perangkat terverifikasi"</string>
<string name="screen_identity_use_another_device">"Gunakan perangkat lain"</string>
<string name="screen_identity_waiting_on_other_device">"Menunggu di perangkat lain…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Sepertinya ada yang tidak beres. Entah permintaan sudah habis masa berlakunya atau permintaan ditolak."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Konfirmasikan bahwa emoji di bawah ini sesuai dengan yang ditampilkan pada sesi Anda yang lain."</string>
<string name="screen_session_verification_compare_emojis_title">"Bandingkan emoji"</string>

View File

@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_subtitle">"Verifica questo dispositivo per segnare i tuoi messaggi come sicuri."</string>
<string name="screen_identity_confirmation_title">"Conferma la tua identità"</string>
<string name="screen_identity_confirmed_subtitle">"Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo."</string>
<string name="screen_identity_confirmed_title">"Dispositivo verificato"</string>
<string name="screen_identity_use_another_device">"Usa un altro dispositivo"</string>
<string name="screen_identity_waiting_on_other_device">"In attesa sull\'altro dispositivo…"</string>
<string name="screen_session_verification_cancelled_subtitle">"C\'è qualcosa che non va. La richiesta è scaduta o è stata rifiutata."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Verifica che gli emoji sottostanti corrispondano a quelli mostrati nell\'altra sessione."</string>
<string name="screen_session_verification_compare_emojis_title">"Confronta le emoji"</string>

View File

@@ -1,12 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">
"Создайте новый "
<b>"ключ восстановления"</b>
</string>
<string name="screen_identity_confirmation_subtitle">"Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями."</string>
<string name="screen_identity_confirmation_title">"Подтвердите, что это вы"</string>
<string name="screen_identity_confirmed_subtitle">"Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."</string>
<string name="screen_identity_confirmed_title">"Устройство проверено"</string>
<string name="screen_identity_use_another_device">"Используйте другое устройство"</string>
<string name="screen_identity_waiting_on_other_device">"Ожидание на другом устройстве…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Похоже, что-то не так. Время ожидания запроса либо истекло, либо запрос был отклонен."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Убедитесь, что приведенные ниже емоджи совпадают с емоджи показанными во время другого сеанса."</string>
<string name="screen_session_verification_compare_emojis_title">"Сравните емодзи"</string>
<string name="screen_session_verification_compare_numbers_subtitle">"Убедитесь, что приведенные ниже числа совпадают с цифрами, показанными в другом сеансе."</string>
<string name="screen_session_verification_compare_numbers_title">"Сравните числа"</string>
<string name="screen_session_verification_complete_subtitle">"Ваш новый сеанс подтвержден. У него есть доступ к вашим зашифрованным сообщениям, и другие пользователи увидят его как доверенное."</string>
<string name="screen_session_verification_enter_recovery_key">"Введите ключ восстановления"</string>
<string name="screen_session_verification_enter_recovery_key">
"Введите "
<b>"ключ восстановления"</b>
</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Чтобы получить доступ к зашифрованной истории сообщений, докажите, что это вы."</string>
<string name="screen_session_verification_open_existing_session_title">"Открыть существующий сеанс"</string>
<string name="screen_session_verification_positive_button_canceled">"Повторить проверку"</string>

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Vytvoriť nový kľúč na obnovenie"</string>
<string name="screen_identity_confirmation_subtitle">"Ak chcete nastaviť zabezpečené správy, overte toto zariadenie."</string>
<string name="screen_identity_confirmation_title">"Potvrďte, že ste to vy"</string>
<string name="screen_identity_confirmed_subtitle">"Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete."</string>
<string name="screen_identity_confirmed_title">"Zariadenie overené"</string>
<string name="screen_identity_use_another_device">"Použiť iné zariadenie"</string>
<string name="screen_identity_waiting_on_other_device">"Čaká sa na druhom zariadení…"</string>
<string name="screen_session_verification_cancelled_subtitle">"Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Skontrolujte, či sa emotikony uvedené nižšie zhodujú s emotikonmi zobrazenými vo vašej druhej relácii."</string>
<string name="screen_session_verification_compare_emojis_title">"Porovnajte emotikony"</string>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_subtitle">"Перевірте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."</string>
<string name="screen_identity_confirmation_title">"Підтвердіть, що це ви"</string>
<string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</string>
<string name="screen_identity_confirmed_title">"Пристрій перевірено"</string>
<string name="screen_session_verification_cancelled_subtitle">"Щось не так. Або час очікування запиту минув, або в запиті було відмовлено."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Переконайтеся, що емодзі нижче збігаються з тими, що відображаються під час іншого сеансу."</string>
<string name="screen_session_verification_compare_emojis_title">"Порівняти емодзі"</string>

View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmed_title">"裝置已認證"</string>
<string name="screen_identity_use_another_device">"使用另一個裝置"</string>
<string name="screen_identity_waiting_on_other_device">"正在等待其他裝置……"</string>
<string name="screen_session_verification_cancelled_subtitle">"似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。"</string>
<string name="screen_session_verification_compare_emojis_subtitle">"確認顯示在其他工作階段上的表情符號是否和下方的相同。"</string>
<string name="screen_session_verification_compare_emojis_title">"比對表情符號"</string>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
<string name="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm that it\'s you"</string>
<string name="screen_identity_confirmed_subtitle">"Now you can read or send messages securely, and anyone you chat with can also trust this device."</string>

View File

@@ -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,7 +137,7 @@ 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.Cancel)
expectNoEvents()
@@ -113,7 +146,7 @@ class VerifySelfSessionPresenterTests {
@Test
fun `present - A failure when verifying cancels it`() = runTest {
val service = FakeSessionVerificationService()
val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -130,7 +163,7 @@ class VerifySelfSessionPresenterTests {
@Test
fun `present - A fail when requesting verification resets the state to the initial one`() = runTest {
val service = FakeSessionVerificationService()
val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -139,13 +172,13 @@ class VerifySelfSessionPresenterTests {
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.shouldFail = false
assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
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()
@@ -158,7 +191,7 @@ class VerifySelfSessionPresenterTests {
@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()
@@ -171,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()
@@ -188,7 +221,7 @@ class VerifySelfSessionPresenterTests {
@Test
fun `present - Go back after cancelation returns to initial state`() = runTest {
val service = FakeSessionVerificationService()
val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -198,7 +231,7 @@ class VerifySelfSessionPresenterTests {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
// Went back to initial state
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false))
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial(false, false))
cancelAndIgnoreRemainingEvents()
}
}
@@ -208,7 +241,7 @@ class VerifySelfSessionPresenterTests {
val emojis = listOf(
VerificationEmoji(number = 30, emoji = "😀", description = "Smiley")
)
val service = FakeSessionVerificationService()
val service = unverifiedSessionService()
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -230,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()
@@ -247,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<VerifySelfSessionState>.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()
@@ -271,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, encryptionService),
buildMeta = buildMeta,
)
}
}

View File

@@ -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
@@ -38,16 +41,12 @@ class VerifySelfSessionViewTest {
@Test
fun `back key pressed - when canceled resets the flow`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
}
@@ -55,16 +54,12 @@ class VerifySelfSessionViewTest {
@Test
fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
}
@@ -72,16 +67,12 @@ class VerifySelfSessionViewTest {
@Test
fun `back key pressed - when ready to verify cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
}
@@ -89,19 +80,15 @@ class VerifySelfSessionViewTest {
@Test
fun `back key pressed - when verifying and not loading declines the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
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(),
onFinished = EnsureNeverCalled(),
)
}
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
}
@@ -109,19 +96,28 @@ class VerifySelfSessionViewTest {
@Test
fun `back key pressed - when verifying and loading does nothing`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(),
),
eventSink = eventsRecorder
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(),
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = EnsureNeverCalled(),
)
}
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertEmpty()
}
@Test
fun `back key pressed - on Completed step does nothing`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertEmpty()
}
@@ -130,16 +126,13 @@ class VerifySelfSessionViewTest {
fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
eventSink = eventsRecorder
),
onEnterRecoveryKey = EnsureNeverCalled(),
onFinished = callback,
)
}
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Completed,
eventSink = eventsRecorder
),
onFinished = callback,
)
rule.clickOn(CommonStrings.action_continue)
}
}
@@ -149,36 +142,45 @@ class VerifySelfSessionViewTest {
fun `clicking on enter recovery key calls the expected callback`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setContent {
VerifySelfSessionView(
aVerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial(true),
eventSink = eventsRecorder
),
onEnterRecoveryKey = callback,
onFinished = 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<VerifySelfSessionViewEvents>(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<VerifySelfSessionViewEvents>()
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(),
onFinished = EnsureNeverCalled(),
)
}
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_session_verification_they_match)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.ConfirmVerification)
}
@@ -186,20 +188,60 @@ class VerifySelfSessionViewTest {
@Test
fun `clicking on they do not match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
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(),
onFinished = 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<VerifySelfSessionViewEvents>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
onCreateNewRecoveryKey: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(),
) {
rule.setContent {
VerifySelfSessionView(
state = state,
onEnterRecoveryKey = onEnterRecoveryKey,
onCreateNewRecoveryKey = onCreateNewRecoveryKey,
onFinished = onFinished,
)
}
}
}

View File

@@ -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,12 +33,12 @@ 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.35.0"
sqldelight = "2.0.2"
wysiwyg = "2.36.0"
telephoto = "0.9.0"
# DI
@@ -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"
@@ -154,7 +154,7 @@ 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.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" }
@@ -176,7 +176,7 @@ kotlinpoet = "com.squareup:kotlinpoet:1.16.0"
# Analytics
posthog = "com.posthog:posthog-android:3.1.16"
sentry = "io.sentry:sentry-android:7.6.0"
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"

View File

@@ -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(

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