Merge branch 'develop' into feature/bma/mediaForward

# Conflicts:
#	appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
This commit is contained in:
Benoit Marty 2025-10-29 12:41:58 +01:00
commit cc050a1f26
43 changed files with 399 additions and 190 deletions

View file

@ -23,8 +23,6 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode.Inputs
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode.NavTarget
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
@ -158,6 +156,9 @@ class JoinedRoomLoadedFlowNode(
NavTarget.RoomNotificationSettings -> { NavTarget.RoomNotificationSettings -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings) createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings)
} }
NavTarget.RoomMemberList -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberList)
}
NavTarget.Space -> { NavTarget.Space -> {
createSpaceNode(buildContext) createSpaceNode(buildContext)
} }
@ -189,6 +190,10 @@ class JoinedRoomLoadedFlowNode(
override fun onOpenDetails() { override fun onOpenDetails() {
backstack.push(NavTarget.RoomDetails) backstack.push(NavTarget.RoomDetails)
} }
override fun onOpenMemberList() {
backstack.push(NavTarget.RoomMemberList)
}
} }
return spaceEntryPoint.nodeBuilder(this, buildContext) return spaceEntryPoint.nodeBuilder(this, buildContext)
.inputs(SpaceEntryPoint.Inputs(roomId = inputs.room.roomId)) .inputs(SpaceEntryPoint.Inputs(roomId = inputs.room.roomId))
@ -240,6 +245,9 @@ class JoinedRoomLoadedFlowNode(
@Parcelize @Parcelize
data object RoomDetails : NavTarget data object RoomDetails : NavTarget
@Parcelize
data object RoomMemberList : NavTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget data class RoomMemberDetails(val userId: UserId) : NavTarget
@ -256,17 +264,17 @@ class JoinedRoomLoadedFlowNode(
} }
} }
private fun initialElement(plugins: List<Plugin>): NavTarget { private fun initialElement(plugins: List<Plugin>): JoinedRoomLoadedFlowNode.NavTarget {
val input = plugins.filterIsInstance<Inputs>().single() val input = plugins.filterIsInstance<JoinedRoomLoadedFlowNode.Inputs>().single()
return when (input.initialElement) { return when (input.initialElement) {
is RoomNavigationTarget.Root -> { is RoomNavigationTarget.Root -> {
if (input.room.roomInfoFlow.value.isSpace) { if (input.room.roomInfoFlow.value.isSpace) {
NavTarget.Space JoinedRoomLoadedFlowNode.NavTarget.Space
} else { } else {
NavTarget.Messages(input.initialElement.eventId) JoinedRoomLoadedFlowNode.NavTarget.Messages(input.initialElement.eventId)
} }
} }
RoomNavigationTarget.Details -> NavTarget.RoomDetails RoomNavigationTarget.Details -> JoinedRoomLoadedFlowNode.NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings RoomNavigationTarget.NotificationSettings -> JoinedRoomLoadedFlowNode.NavTarget.RoomNotificationSettings
} }
} }

View file

@ -15,7 +15,5 @@ sealed interface ChangeRolesEvent {
data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent
data object Save : ChangeRolesEvent data object Save : ChangeRolesEvent
data object Exit : ChangeRolesEvent data object Exit : ChangeRolesEvent
data object CancelExit : ChangeRolesEvent data object CloseDialog : ChangeRolesEvent
data object ClearError : ChangeRolesEvent
data object CancelSave : ChangeRolesEvent
} }

View file

@ -69,20 +69,19 @@ class ChangeRolesPresenter(
val selectedUsers = remember { val selectedUsers = remember {
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
} }
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val saveState: MutableState<AsyncAction<Boolean>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val usersWithRole = produceState<ImmutableList<MatrixUser>>(initialValue = persistentListOf()) { val usersWithRole = produceState<ImmutableList<MatrixUser>>(initialValue = persistentListOf()) {
room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } } room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } }
.onEach { users -> .onEach { users ->
val previous = value val previous = value
value = users.toImmutableList() value = users.toImmutableList()
// Users who were selected but didn't have the role, so their role change was pending // 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 } } 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 // Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet() val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList() selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
} }
.launchIn(this) .launchIn(this)
} }
val roomMemberState by room.membersStateFlow.collectAsState() val roomMemberState by room.membersStateFlow.collectAsState()
@ -147,22 +146,16 @@ class ChangeRolesPresenter(
} }
} }
} }
is ChangeRolesEvent.ClearError -> {
saveState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.Exit -> { is ChangeRolesEvent.Exit -> {
exitState.value = if (exitState.value.isUninitialized() && hasPendingChanges) { saveState.value = if (saveState.value.isUninitialized() && hasPendingChanges) {
// Has pending changes, confirm exit // Has pending changes, confirm exit
AsyncAction.ConfirmingNoParams AsyncAction.ConfirmingCancellation
} else { } else {
// No pending changes, exit immediately // No pending changes, exit immediately
AsyncAction.Success(Unit) AsyncAction.Success(false)
} }
} }
is ChangeRolesEvent.CancelExit -> { is ChangeRolesEvent.CloseDialog -> {
exitState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.CancelSave -> {
saveState.value = AsyncAction.Uninitialized saveState.value = AsyncAction.Uninitialized
} }
} }
@ -174,7 +167,6 @@ class ChangeRolesPresenter(
searchResults = searchResults, searchResults = searchResults,
selectedUsers = selectedUsers.value, selectedUsers = selectedUsers.value,
hasPendingChanges = hasPendingChanges, hasPendingChanges = hasPendingChanges,
exitState = exitState.value,
savingState = saveState.value, savingState = saveState.value,
canChangeMemberRole = ::canChangeMemberRole, canChangeMemberRole = ::canChangeMemberRole,
eventSink = ::handleEvent, eventSink = ::handleEvent,
@ -198,7 +190,7 @@ class ChangeRolesPresenter(
private fun CoroutineScope.save( private fun CoroutineScope.save(
usersWithRole: ImmutableList<MatrixUser>, usersWithRole: ImmutableList<MatrixUser>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>, selectedUsers: MutableState<ImmutableList<MatrixUser>>,
saveState: MutableState<AsyncAction<Unit>>, saveState: MutableState<AsyncAction<Boolean>>,
) = launch { ) = launch {
saveState.value = AsyncAction.Loading saveState.value = AsyncAction.Loading
@ -221,7 +213,7 @@ class ChangeRolesPresenter(
saveState.value = AsyncAction.Failure(it) saveState.value = AsyncAction.Failure(it)
} }
.onSuccess { .onSuccess {
saveState.value = AsyncAction.Success(Unit) saveState.value = AsyncAction.Success(true)
// Asynchronously reload the room members // Asynchronously reload the room members
launch { room.updateMembers() } launch { room.updateMembers() }
} }

View file

@ -23,8 +23,7 @@ data class ChangeRolesState(
val searchResults: SearchBarResultState<MembersByRole>, val searchResults: SearchBarResultState<MembersByRole>,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val hasPendingChanges: Boolean, val hasPendingChanges: Boolean,
val exitState: AsyncAction<Unit>, val savingState: AsyncAction<Boolean>,
val savingState: AsyncAction<Unit>,
val canChangeMemberRole: (UserId) -> Boolean, val canChangeMemberRole: (UserId) -> Boolean,
val eventSink: (ChangeRolesEvent) -> Unit, val eventSink: (ChangeRolesEvent) -> Unit,
) )
@ -36,10 +35,10 @@ data class MembersByRole(
val members: ImmutableList<RoomMember>, val members: ImmutableList<RoomMember>,
) { ) {
constructor(members: List<RoomMember>) : this( constructor(members: List<RoomMember>) : this(
owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(), owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(),
admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(), admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(),
moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(), moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(),
members = members.filter { it.role == RoomMember.Role.User }.sorted(), members = members.filter { it.role == RoomMember.Role.User }.sorted(),
) )
fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty() fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty()

View file

@ -38,10 +38,10 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())), searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
selectedUsers = aMatrixUserList().take(1).toImmutableList(), selectedUsers = aMatrixUserList().take(1).toImmutableList(),
), ),
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.ConfirmingNoParams), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingNoParams), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingNoParams),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(true)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
aChangeRolesStateWithOwners(), aChangeRolesStateWithOwners(),
aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)), aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)),
@ -55,8 +55,7 @@ internal fun aChangeRolesState(
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(), searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(), selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
hasPendingChanges: Boolean = false, hasPendingChanges: Boolean = false,
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized, savingState: AsyncAction<Boolean> = AsyncAction.Uninitialized,
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
canRemoveMember: (UserId) -> Boolean = { true }, canRemoveMember: (UserId) -> Boolean = { true },
eventSink: (ChangeRolesEvent) -> Unit = {}, eventSink: (ChangeRolesEvent) -> Unit = {},
) = ChangeRolesState( ) = ChangeRolesState(
@ -66,7 +65,6 @@ internal fun aChangeRolesState(
searchResults = searchResults, searchResults = searchResults,
selectedUsers = selectedUsers, selectedUsers = selectedUsers,
hasPendingChanges = hasPendingChanges, hasPendingChanges = hasPendingChanges,
exitState = exitState,
savingState = savingState, savingState = savingState,
canChangeMemberRole = canRemoveMember, canChangeMemberRole = canRemoveMember,
eventSink = eventSink, eventSink = eventSink,

View file

@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -41,7 +40,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.AsyncAction 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.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncIndicator 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.AsyncIndicatorHost
@ -52,7 +50,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton 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.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.Checkbox
@ -172,61 +169,59 @@ fun ChangeRolesView(
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState) AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
AsyncActionView( AsyncActionView(
async = state.exitState, async = state.savingState,
onSuccess = { latestNavigateUp() }, onSuccess = { changeSaved ->
confirmationDialog = { if (changeSaved) {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) }
)
},
onErrorDismiss = { /* Cannot happen */ },
)
when (state.savingState) {
is AsyncAction.Confirming -> {
when (state.role) {
is RoomMember.Role.Owner -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) },
destructiveSubmit = true,
)
}
is RoomMember.Role.Admin -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
else -> Unit // No confirmation needed for Moderator or User roles
}
}
is AsyncAction.Loading -> {
ProgressDialog()
}
is AsyncAction.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
is AsyncAction.Success -> {
LaunchedEffect(state.savingState) {
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) { asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes)) AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
} }
} else {
latestNavigateUp()
} }
} },
else -> Unit confirmationDialog = { confirming ->
} when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
)
}
else -> {
when (state.role) {
is RoomMember.Role.Owner -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) },
destructiveSubmit = true,
)
}
is RoomMember.Role.Admin -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
)
}
// No confirmation needed for Moderator or User roles
else -> Unit
}
}
}
},
errorMessage = {
stringResource(CommonStrings.error_unknown)
},
onErrorDismiss = {
state.eventSink(ChangeRolesEvent.CloseDialog)
},
)
} }
} }

View file

@ -52,7 +52,6 @@ class ChangeRolesPresenterTest {
assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(selectedUsers).isEmpty() assertThat(selectedUsers).isEmpty()
assertThat(hasPendingChanges).isFalse() assertThat(hasPendingChanges).isFalse()
assertThat(exitState).isEqualTo(AsyncAction.Uninitialized)
assertThat(savingState).isEqualTo(AsyncAction.Uninitialized) assertThat(savingState).isEqualTo(AsyncAction.Uninitialized)
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -266,7 +265,7 @@ class ChangeRolesPresenterTest {
} }
@Test @Test
fun `present - Exit will display success if no pending changes`() = runTest { fun `present - Exit will display success false if no pending changes`() = runTest {
val room = FakeJoinedRoom().apply { val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
@ -278,15 +277,15 @@ class ChangeRolesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(ChangeRolesEvent.Exit) initialState.eventSink(ChangeRolesEvent.Exit)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false))
} }
} }
@Test @Test
fun `present - CancelExit will remove exit confirmation`() = runTest { fun `present - CloseDialog will remove exit confirmation`() = runTest {
val room = FakeJoinedRoom().apply { val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
@ -298,16 +297,16 @@ class ChangeRolesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Exit) awaitItem().eventSink(ChangeRolesEvent.Exit)
val confirmingState = awaitItem() val confirmingState = awaitItem()
assertThat(confirmingState.exitState).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingCancellation)
confirmingState.eventSink(ChangeRolesEvent.CancelExit) confirmingState.eventSink(ChangeRolesEvent.CloseDialog)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Uninitialized) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
} }
} }
@ -324,7 +323,7 @@ class ChangeRolesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.hasPendingChanges).isFalse()
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
val updatedState = awaitItem() val updatedState = awaitItem()
@ -332,10 +331,10 @@ class ChangeRolesPresenterTest {
skipItems(1) skipItems(1)
updatedState.eventSink(ChangeRolesEvent.Exit) updatedState.eventSink(ChangeRolesEvent.Exit)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.ConfirmingCancellation)
updatedState.eventSink(ChangeRolesEvent.Exit) updatedState.eventSink(ChangeRolesEvent.Exit)
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false))
} }
} }
@ -367,12 +366,12 @@ class ChangeRolesPresenterTest {
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1) skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
} }
} }
@Test @Test
fun `present - CancelSave will remove the confirmation dialog`() = runTest { fun `present - CloseDialog will remove the confirmation dialog`() = runTest {
val room = FakeJoinedRoom().apply { val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
@ -391,7 +390,7 @@ class ChangeRolesPresenterTest {
val confirmingState = awaitItem() val confirmingState = awaitItem()
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams)
confirmingState.eventSink(ChangeRolesEvent.CancelSave) confirmingState.eventSink(ChangeRolesEvent.CloseDialog)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
} }
} }
@ -426,7 +425,7 @@ class ChangeRolesPresenterTest {
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1) skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator)) assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator))
} }
} }
@ -504,13 +503,13 @@ class ChangeRolesPresenterTest {
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1) skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit)) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User)) assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
} }
} }
@Test @Test
fun `present - Save can handle failures and ClearError clears them`() = runTest { fun `present - Save can handle failures and CloseDialog clears them`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) } updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) }
).apply { ).apply {
@ -534,7 +533,7 @@ class ChangeRolesPresenterTest {
val failedState = awaitItem() val failedState = awaitItem()
assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java) assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java)
failedState.eventSink(ChangeRolesEvent.ClearError) failedState.eventSink(ChangeRolesEvent.CloseDialog)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
} }
} }

View file

@ -135,7 +135,7 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = aChangeRolesState( state = aChangeRolesState(
isSearchActive = true, isSearchActive = true,
exitState = AsyncAction.ConfirmingNoParams, savingState = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
@ -151,14 +151,14 @@ class ChangeRolesViewTest {
rule.setChangeRolesContent( rule.setChangeRolesContent(
state = aChangeRolesState( state = aChangeRolesState(
isSearchActive = true, isSearchActive = true,
exitState = AsyncAction.ConfirmingNoParams, savingState = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit) eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
} }
@Test @Test
@ -209,7 +209,7 @@ class ChangeRolesViewTest {
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError) eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
} }
@Test @Test
@ -225,7 +225,7 @@ class ChangeRolesViewTest {
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError) eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog)
} }
@Test @Test

View file

@ -24,6 +24,9 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
@Parcelize @Parcelize
data object RoomDetails : InitialTarget data object RoomDetails : InitialTarget
@Parcelize
data object RoomMemberList : InitialTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget

View file

@ -44,4 +44,5 @@ internal fun InitialTarget.toNavTarget() = when (this) {
is InitialTarget.RoomDetails -> NavTarget.RoomDetails is InitialTarget.RoomDetails -> NavTarget.RoomDetails
is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId)
is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true) is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true)
InitialTarget.RoomMemberList -> NavTarget.RoomMemberList
} }

View file

@ -13,6 +13,7 @@ sealed interface RoomDetailsEditEvents {
data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents
data class UpdateRoomName(val name: String) : RoomDetailsEditEvents data class UpdateRoomName(val name: String) : RoomDetailsEditEvents
data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents
data object OnBackPress : RoomDetailsEditEvents
data object Save : RoomDetailsEditEvents data object Save : RoomDetailsEditEvents
data object CancelSaveChanges : RoomDetailsEditEvents data object CloseDialog : RoomDetailsEditEvents
} }

View file

@ -41,8 +41,7 @@ class RoomDetailsEditNode(
val state = presenter.present() val state = presenter.present()
RoomDetailsEditView( RoomDetailsEditView(
state = state, state = state,
onBackClick = ::navigateUp, onDone = ::navigateUp,
onRoomEditSuccess = ::navigateUp,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -169,7 +169,13 @@ class RoomDetailsEditPresenter(
is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name
is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic
RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized RoomDetailsEditEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
RoomDetailsEditEvents.OnBackPress -> if (saveButtonEnabled.not() || saveAction.value == AsyncAction.ConfirmingCancellation) {
// No changes to save or already confirming exit without saving
saveAction.value = AsyncAction.Success(Unit)
} else {
saveAction.value = AsyncAction.ConfirmingCancellation
}
} }
} }

View file

@ -26,6 +26,7 @@ open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEd
aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false), aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
aRoomDetailsEditState(saveAction = AsyncAction.Loading), aRoomDetailsEditState(saveAction = AsyncAction.Loading),
aRoomDetailsEditState(saveAction = AsyncAction.Failure(RuntimeException("Whelp"))), aRoomDetailsEditState(saveAction = AsyncAction.Failure(RuntimeException("Whelp"))),
aRoomDetailsEditState(saveAction = AsyncAction.ConfirmingCancellation),
) )
} }

View file

@ -9,6 +9,7 @@
package io.element.android.features.roomdetails.impl.edit package io.element.android.features.roomdetails.impl.edit
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -30,11 +31,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView 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.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -50,8 +53,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun RoomDetailsEditView( fun RoomDetailsEditView(
state: RoomDetailsEditState, state: RoomDetailsEditState,
onBackClick: () -> Unit, onDone: () -> Unit,
onRoomEditSuccess: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@ -62,12 +64,21 @@ fun RoomDetailsEditView(
isAvatarActionsSheetVisible.value = true isAvatarActionsSheetVisible.value = true
} }
BackHandler {
state.eventSink(RoomDetailsEditEvents.OnBackPress)
}
Scaffold( Scaffold(
modifier = modifier.clearFocusOnTap(focusManager), modifier = modifier.clearFocusOnTap(focusManager),
topBar = { topBar = {
TopAppBar( TopAppBar(
titleStr = stringResource(id = R.string.screen_room_details_edit_room_title), titleStr = stringResource(id = R.string.screen_room_details_edit_room_title),
navigationIcon = { BackButton(onClick = onBackClick) }, navigationIcon = {
BackButton(
onClick = {
state.eventSink(RoomDetailsEditEvents.OnBackPress)
}
)
},
actions = { actions = {
TextButton( TextButton(
text = stringResource(CommonStrings.action_save), text = stringResource(CommonStrings.action_save),
@ -126,14 +137,12 @@ fun RoomDetailsEditView(
) )
} }
} }
AvatarActionBottomSheet( AvatarActionBottomSheet(
actions = state.avatarActions, actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value, isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false }, onDismiss = { isAvatarActionsSheetVisible.value = false },
onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) }
) )
AsyncActionView( AsyncActionView(
async = state.saveAction, async = state.saveAction,
progressDialog = { progressDialog = {
@ -141,9 +150,19 @@ fun RoomDetailsEditView(
progressText = stringResource(R.string.screen_room_details_updating_room), progressText = stringResource(R.string.screen_room_details_updating_room),
) )
}, },
onSuccess = { onRoomEditSuccess() }, confirmationDialog = {
if (state.saveAction == AsyncAction.ConfirmingCancellation) {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) },
onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) }
)
}
},
onSuccess = { onDone() },
errorMessage = { stringResource(R.string.screen_room_details_edition_error) }, errorMessage = { stringResource(R.string.screen_room_details_edition_error) },
onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CancelSaveChanges) } onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) }
) )
PermissionsView( PermissionsView(
@ -156,7 +175,6 @@ fun RoomDetailsEditView(
internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview { internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview {
RoomDetailsEditView( RoomDetailsEditView(
state = state, state = state,
onBackClick = {}, onDone = {},
onRoomEditSuccess = {},
) )
} }

View file

@ -41,7 +41,8 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
), ),
banned = persistentListOf(), banned = persistentListOf(),
) )
) ),
moderationState = aRoomMemberModerationState(canBan = true)
), ),
aRoomMemberListState(roomMembers = AsyncData.Loading()), aRoomMemberListState(roomMembers = AsyncData.Loading()),
aRoomMemberListState().copy(canInvite = true), aRoomMemberListState().copy(canInvite = true),

View file

@ -649,11 +649,88 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.Save) initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(3) skipItems(3)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges) initialState.eventSink(RoomDetailsEditEvents.CloseDialog)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
@Test
fun `present - leave without saving - cancel`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
room = room,
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
eventSink(RoomDetailsEditEvents.OnBackPress)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
eventSink(RoomDetailsEditEvents.CloseDialog)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@Test
fun `present - leave no changes, no confirmation`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(
room = room,
temporaryUriDeleter = FakeTemporaryUriDeleter {},
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
initialState.eventSink(RoomDetailsEditEvents.OnBackPress)
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(Unit))
}
}
@Test
fun `present - leave without saving - confirm`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(
room = room,
temporaryUriDeleter = FakeTemporaryUriDeleter({}),
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
eventSink(RoomDetailsEditEvents.OnBackPress)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
eventSink(RoomDetailsEditEvents.OnBackPress)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
}
}
private suspend fun saveAndAssertFailure( private suspend fun saveAndAssertFailure(
room: JoinedRoom, room: JoinedRoom,
event: RoomDetailsEditEvents, event: RoomDetailsEditEvents,

View file

@ -38,17 +38,41 @@ class RoomDetailsEditViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invoke back callback`() { fun `clicking on back emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
ensureCalledOnce { callback -> rule.setRoomDetailsEditView(
rule.setRoomDetailsEditView( aRoomDetailsEditState(
aRoomDetailsEditState( eventSink = eventsRecorder
eventSink = eventsRecorder ),
), )
onBackClick = callback, rule.pressBack()
) eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress)
rule.pressBack() }
}
@Test
fun `clicking on OK when confirming exit emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress)
}
@Test
fun `clicking on cancel when confirming exit emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog)
} }
@Test @Test
@ -60,7 +84,7 @@ class RoomDetailsEditViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
saveAction = AsyncAction.Success(Unit) saveAction = AsyncAction.Success(Unit)
), ),
onRoomEdited = callback, onDone = callback,
) )
} }
} }
@ -209,20 +233,18 @@ class RoomDetailsEditViewTest {
), ),
) )
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(RoomDetailsEditEvents.CancelSaveChanges) eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailsEditView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailsEditView(
state: RoomDetailsEditState, state: RoomDetailsEditState,
onBackClick: () -> Unit = EnsureNeverCalled(), onDone: () -> Unit = EnsureNeverCalled(),
onRoomEdited: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
RoomDetailsEditView( RoomDetailsEditView(
state = state, state = state,
onBackClick = onBackClick, onDone = onDone,
onRoomEditSuccess = onRoomEdited,
) )
} }
} }

View file

@ -33,5 +33,7 @@ interface SpaceEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) fun onOpenRoom(roomId: RoomId, viaParameters: List<String>)
fun onOpenDetails() fun onOpenDetails()
fun onOpenMemberList()
} }
} }

View file

@ -88,6 +88,10 @@ class SpaceFlowNode(
callback.onOpenDetails() callback.onOpenDetails()
} }
override fun onOpenMemberList() {
callback.onOpenMemberList()
}
override fun onLeaveSpace() { override fun onLeaveSpace() {
backstack.push(NavTarget.Leave) backstack.push(NavTarget.Leave)
} }

View file

@ -42,6 +42,8 @@ class SpaceNode(
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) fun onOpenRoom(roomId: RoomId, viaParameters: List<String>)
fun onOpenDetails() fun onOpenDetails()
fun onOpenMemberList()
fun onLeaveSpace() fun onLeaveSpace()
} }
@ -83,6 +85,9 @@ class SpaceNode(
onShareSpace = { onShareSpace = {
onShareRoom(context) onShareRoom(context)
}, },
onViewMembersClick = {
callback.onOpenMemberList()
},
acceptDeclineInviteView = { acceptDeclineInviteView = {
acceptDeclineInviteView.Render( acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState, state = state.acceptDeclineInviteState,

View file

@ -78,6 +78,7 @@ fun SpaceView(
onShareSpace: () -> Unit, onShareSpace: () -> Unit,
onLeaveSpaceClick: () -> Unit, onLeaveSpaceClick: () -> Unit,
onDetailsClick: () -> Unit, onDetailsClick: () -> Unit,
onViewMembersClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit, acceptDeclineInviteView: @Composable () -> Unit,
) { ) {
@ -89,7 +90,8 @@ fun SpaceView(
onBackClick = onBackClick, onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick, onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace, onShareSpace = onShareSpace,
onDetailsClick = onDetailsClick onDetailsClick = onDetailsClick,
onViewMembersClick = onViewMembersClick,
) )
}, },
content = { padding -> content = { padding ->
@ -256,6 +258,7 @@ private fun SpaceViewTopBar(
onLeaveSpaceClick: () -> Unit, onLeaveSpaceClick: () -> Unit,
onDetailsClick: () -> Unit, onDetailsClick: () -> Unit,
onShareSpace: () -> Unit, onShareSpace: () -> Unit,
onViewMembersClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
TopAppBar( TopAppBar(
@ -304,6 +307,20 @@ private fun SpaceViewTopBar(
) )
} }
) )
DropdownMenuItem(
onClick = {
showMenu = false
onViewMembersClick()
},
text = { Text(stringResource(id = CommonStrings.screen_space_menu_action_members)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.User(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
DropdownMenuItem( DropdownMenuItem(
onClick = { onClick = {
showMenu = false showMenu = false
@ -344,10 +361,10 @@ private fun SpaceAvatarAndNameRow(
) )
Text( Text(
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.semantics { .semantics {
heading() heading()
}, },
text = name ?: stringResource(CommonStrings.common_no_space_name), text = name ?: stringResource(CommonStrings.common_no_space_name),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { name == null }, fontStyle = FontStyle.Italic.takeIf { name == null },
@ -403,6 +420,7 @@ internal fun SpaceViewPreview(
onLeaveSpaceClick = {}, onLeaveSpaceClick = {},
acceptDeclineInviteView = {}, acceptDeclineInviteView = {},
onDetailsClick = {}, onDetailsClick = {},
onViewMembersClick = {},
onBackClick = {}, onBackClick = {},
) )
} }

View file

@ -46,6 +46,7 @@ class DefaultSpaceEntryPointTest {
val callback = object : SpaceEntryPoint.Callback { val callback = object : SpaceEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) = lambdaError() override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) = lambdaError()
override fun onOpenDetails() = lambdaError() override fun onOpenDetails() = lambdaError()
override fun onOpenMemberList() = lambdaError()
} }
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null)) val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.inputs(nodeInputs) .inputs(nodeInputs)

View file

@ -140,6 +140,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onShareSpace: () -> Unit = EnsureNeverCalled(), onShareSpace: () -> Unit = EnsureNeverCalled(),
onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(), onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(),
onDetailsClick: () -> Unit = EnsureNeverCalled(), onDetailsClick: () -> Unit = EnsureNeverCalled(),
onViewMembersClick: () -> Unit = EnsureNeverCalled(),
acceptDeclineInviteView: @Composable () -> Unit = {}, acceptDeclineInviteView: @Composable () -> Unit = {},
) { ) {
setContent { setContent {
@ -150,6 +151,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onShareSpace = onShareSpace, onShareSpace = onShareSpace,
onLeaveSpaceClick = onLeaveSpaceClick, onLeaveSpaceClick = onLeaveSpaceClick,
onDetailsClick = onDetailsClick, onDetailsClick = onDetailsClick,
onViewMembersClick = onViewMembersClick,
acceptDeclineInviteView = acceptDeclineInviteView, acceptDeclineInviteView = acceptDeclineInviteView,
) )
} }

View file

@ -50,7 +50,7 @@ telephoto = "0.18.0"
haze = "1.6.10" haze = "1.6.10"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "3.2.0" dependencyAnalysis = "3.4.0"
# DI # DI
metro = "0.7.2" metro = "0.7.2"
@ -171,7 +171,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should # All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs. # only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.13" matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.28"
# Others # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@ -213,7 +213,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
posthog = "com.posthog:posthog-android:3.25.0" posthog = "com.posthog:posthog-android:3.25.0"
sentry = "io.sentry:sentry-android:8.24.0" sentry = "io.sentry:sentry-android:8.24.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2"
# Emojibase # Emojibase
matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.4.3" matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.4.3"

View file

@ -32,6 +32,11 @@ sealed interface AsyncAction<out T> {
data object ConfirmingNoParams : Confirming data object ConfirmingNoParams : Confirming
/**
* User cancels the action, use this object to ask for confirmation.
*/
data object ConfirmingCancellation : Confirming
/** /**
* Represents an operation that is currently ongoing. * Represents an operation that is currently ongoing.
*/ */

View file

@ -17,4 +17,6 @@ sealed class QrLoginException : Exception() {
data object SlidingSyncNotAvailable : QrLoginException() data object SlidingSyncNotAvailable : QrLoginException()
data object OtherDeviceNotSignedIn : QrLoginException() data object OtherDeviceNotSignedIn : QrLoginException()
data object Unknown : QrLoginException() data object Unknown : QrLoginException()
data object CheckCodeAlreadySent : QrLoginException()
data object CheckCodeCannotBeSent : QrLoginException()
} }

View file

@ -289,10 +289,13 @@ class RustMatrixAuthenticationService(
qrCodeData = sdkQrCodeLoginData, qrCodeData = sdkQrCodeLoginData,
) )
client.loginWithQrCode( client.loginWithQrCode(
qrCodeData = qrCodeData.rustQrCodeData,
oidcConfiguration = oidcConfiguration, oidcConfiguration = oidcConfiguration,
progressListener = progressListener, ).use {
) it.scan(
qrCodeData = qrCodeData.rustQrCodeData,
progressListener = progressListener,
)
}
// Ensure that the user is not already logged in with the same account // Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client) ensureNotAlreadyLoggedIn(client)
val sessionData = client.session() val sessionData = client.session()

View file

@ -42,5 +42,7 @@ object QrErrorMapper {
is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown
is RustHumanQrLoginException.OidcMetadataInvalid -> QrLoginException.OidcMetadataInvalid is RustHumanQrLoginException.OidcMetadataInvalid -> QrLoginException.OidcMetadataInvalid
is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable
is RustHumanQrLoginException.CheckCodeAlreadySent -> QrLoginException.CheckCodeAlreadySent
is RustHumanQrLoginException.CheckCodeCannotBeSent -> QrLoginException.CheckCodeCannotBeSent
} }
} }

View file

@ -17,7 +17,9 @@ internal fun ComposerDraft.into(): RustComposerDraft {
return RustComposerDraft( return RustComposerDraft(
plainText = plainText, plainText = plainText,
htmlText = htmlText, htmlText = htmlText,
draftType = draftType.into() draftType = draftType.into(),
// TODO add media attachments to the draft
attachments = emptyList(),
) )
} }

View file

@ -31,10 +31,6 @@ object NotificationIdProvider {
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
} }
fun getCallNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + ROOM_CALL_NOTIFICATION_ID
}
fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int { fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int {
return type.id * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID return type.id * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID
} }
@ -49,7 +45,6 @@ object NotificationIdProvider {
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2 private const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3 private const val ROOM_INVITATION_NOTIFICATION_ID = 3
private const val ROOM_CALL_NOTIFICATION_ID = 3
private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4 private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4
} }

View file

@ -7,7 +7,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application> <application>

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest
import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter
import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -49,6 +50,7 @@ class DefaultNotificationResolverQueue(
private val workManagerScheduler: WorkManagerScheduler, private val workManagerScheduler: WorkManagerScheduler,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val workerDataConverter: WorkerDataConverter, private val workerDataConverter: WorkerDataConverter,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : NotificationResolverQueue { ) : NotificationResolverQueue {
companion object { companion object {
private const val BATCH_WINDOW_MS = 250L private const val BATCH_WINDOW_MS = 250L
@ -100,6 +102,7 @@ class DefaultNotificationResolverQueue(
sessionId = sessionId, sessionId = sessionId,
notificationEventRequests = requests, notificationEventRequests = requests,
workerDataConverter = workerDataConverter, workerDataConverter = workerDataConverter,
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
) )
) )
} }

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.push.impl.notifications.NotificationResolver
import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
import io.element.android.libraries.workmanager.api.di.WorkerKey import io.element.android.libraries.workmanager.api.di.WorkerKey
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -39,7 +40,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject @AssistedInject
class FetchNotificationsWorker( class FetchNotificationsWorker(
@Assisted workerParams: WorkerParameters, @Assisted workerParams: WorkerParameters,
@ApplicationContext context: Context, @ApplicationContext private val context: Context,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val eventResolver: NotifiableEventResolver, private val eventResolver: NotifiableEventResolver,
private val queue: NotificationResolverQueue, private val queue: NotificationResolverQueue,
@ -47,6 +48,7 @@ class FetchNotificationsWorker(
private val syncOnNotifiableEvent: SyncOnNotifiableEvent, private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val workerDataConverter: WorkerDataConverter, private val workerDataConverter: WorkerDataConverter,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) { override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) {
Timber.d("FetchNotificationsWorker started") Timber.d("FetchNotificationsWorker started")
@ -88,6 +90,7 @@ class FetchNotificationsWorker(
sessionId = failedSessionId, sessionId = failedSessionId,
notificationEventRequests = requestsToRetry, notificationEventRequests = requestsToRetry,
workerDataConverter = workerDataConverter, workerDataConverter = workerDataConverter,
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
) )
) )
} }

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.push.impl.workmanager package io.element.android.libraries.push.impl.workmanager
import android.os.Build
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkRequest import androidx.work.WorkRequest
@ -15,6 +16,7 @@ import io.element.android.libraries.push.api.push.NotificationEventRequest
import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequest
import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.workManagerTag import io.element.android.libraries.workmanager.api.workManagerTag
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import timber.log.Timber import timber.log.Timber
@ -24,6 +26,7 @@ class SyncNotificationWorkManagerRequest(
private val sessionId: SessionId, private val sessionId: SessionId,
private val notificationEventRequests: List<NotificationEventRequest>, private val notificationEventRequests: List<NotificationEventRequest>,
private val workerDataConverter: WorkerDataConverter, private val workerDataConverter: WorkerDataConverter,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : WorkManagerRequest { ) : WorkManagerRequest {
override fun build(): Result<WorkRequest> { override fun build(): Result<WorkRequest> {
if (notificationEventRequests.isEmpty()) { if (notificationEventRequests.isEmpty()) {
@ -36,7 +39,14 @@ class SyncNotificationWorkManagerRequest(
return Result.success( return Result.success(
OneTimeWorkRequestBuilder<FetchNotificationsWorker>() OneTimeWorkRequestBuilder<FetchNotificationsWorker>()
.setInputData(data) .setInputData(data)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .apply {
// Expedited workers aren't needed on Android 12 or lower:
// They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway
// See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
}
}
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
// TODO investigate using this instead of the resolver queue // TODO investigate using this instead of the resolver queue
// .setInputMerger() // .setInputMerger()

View file

@ -55,6 +55,7 @@ import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushSto
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequest
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
@ -717,6 +718,7 @@ class DefaultPushHandlerTest {
workManagerScheduler = workManagerScheduler, workManagerScheduler = workManagerScheduler,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), workerDataConverter = WorkerDataConverter(DefaultJsonProvider()),
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
), ),
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
fallbackNotificationFactory = FallbackNotificationFactory( fallbackNotificationFactory = FallbackNotificationFactory(

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.push.test.notifications.FakeNotificationReso
import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequest
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -176,6 +177,7 @@ class FetchNotificationWorkerTest {
syncOnNotifiableEvent = syncOnNotifiableEvent, syncOnNotifiableEvent = syncOnNotifiableEvent,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), workerDataConverter = WorkerDataConverter(DefaultJsonProvider()),
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
) )
private fun TestScope.createWorkerParams( private fun TestScope.createWorkerParams(

View file

@ -17,15 +17,17 @@ import io.element.android.libraries.push.api.push.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest
import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.workManagerTag import io.element.android.libraries.workmanager.api.workManagerTag
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class SyncNotificationWorkManagerRequestTest { class SyncNotificationWorkManagerRequestTest {
@Test @Test
fun `build - success`() = runTest { fun `build - success API 33`() = runTest {
val request = createSyncNotificationWorkManagerRequest( val request = createSyncNotificationWorkManagerRequest(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
notificationEventRequests = listOf(aNotificationEventRequest()) notificationEventRequests = listOf(aNotificationEventRequest()),
sdkVersion = 33,
) )
val result = request.build() val result = request.build()
@ -33,11 +35,31 @@ class SyncNotificationWorkManagerRequestTest {
result.getOrNull()!!.run { result.getOrNull()!!.run {
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
assertThat(workSpec.input.hasKeyWithValueOfType<String>("requests")).isTrue() assertThat(workSpec.input.hasKeyWithValueOfType<String>("requests")).isTrue()
// True in API 33+
assertThat(workSpec.expedited).isTrue() assertThat(workSpec.expedited).isTrue()
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
} }
} }
@Test
fun `build - success API 32 and lower`() = runTest {
val request = createSyncNotificationWorkManagerRequest(
sessionId = A_SESSION_ID,
notificationEventRequests = listOf(aNotificationEventRequest()),
sdkVersion = 32,
)
val result = request.build()
assertThat(result.isSuccess).isTrue()
result.getOrNull()!!.run {
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
assertThat(workSpec.input.hasKeyWithValueOfType<String>("requests")).isTrue()
// False before API 33
assertThat(workSpec.expedited).isFalse()
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
}
}
@Test @Test
fun `build - empty list of requests fails`() = runTest { fun `build - empty list of requests fails`() = runTest {
val request = createSyncNotificationWorkManagerRequest( val request = createSyncNotificationWorkManagerRequest(
@ -64,9 +86,11 @@ class SyncNotificationWorkManagerRequestTest {
private fun createSyncNotificationWorkManagerRequest( private fun createSyncNotificationWorkManagerRequest(
sessionId: SessionId, sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>, notificationEventRequests: List<NotificationEventRequest>,
workerDataConverter: WorkerDataConverter = WorkerDataConverter(DefaultJsonProvider()) workerDataConverter: WorkerDataConverter = WorkerDataConverter(DefaultJsonProvider()),
sdkVersion: Int = 33,
) = SyncNotificationWorkManagerRequest( ) = SyncNotificationWorkManagerRequest(
sessionId = sessionId, sessionId = sessionId,
notificationEventRequests = notificationEventRequests, notificationEventRequests = notificationEventRequests,
workerDataConverter = workerDataConverter, workerDataConverter = workerDataConverter,
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion),
) )

View file

@ -5,11 +5,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import java.net.URI
pluginManagement { pluginManagement {
repositories { repositories {
includeBuild("plugins") includeBuild("plugins")
gradlePluginPortal() gradlePluginPortal()
google() google()
mavenCentral() mavenCentral()
@ -18,14 +16,17 @@ pluginManagement {
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
google()
mavenCentral()
maven { maven {
url = URI("https://www.jitpack.io") url = uri("https://www.jitpack.io")
content { content {
includeModule("com.github.matrix-org", "matrix-analytics-events") includeModule("com.github.matrix-org", "matrix-analytics-events")
} }
} }
google()
mavenCentral()
maven {
url = uri("https://repo1.maven.org/maven2/")
}
flatDir { flatDir {
dirs("libraries/matrix/libs") dirs("libraries/matrix/libs")
} }

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2cd78ece31258d6ee07eabc282f935e6228893d52d24e00e0bbd129138ed8c31
size 30836

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2bf7bc223f5d481ec8bd1f2a6711751a3bb82ac8f0d856cb5f6c8f4ee7ef50e1
size 28891

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:37940d14ca14d65256ea220cfd7f0685ba2d100425f65af4d4cffe35be4b69fb oid sha256:0cd12c68415f61b198696a9f9d8b19da5e6ced287e015071a8a850100238862b
size 45485 size 49992

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4c45c582fb6698e70b376f832b9564946a2271a6b3532886239dfd75d8bb9755 oid sha256:be897269967951ea0ddb0cc209005d07d4904531a8ab9d865b6e622c9ba98e18
size 45409 size 49747