From 2863d7e1a273a4537de526b3e8654d78c80eec98 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 11 Sep 2026 14:29:39 -0300 Subject: [PATCH] Convert group permissions page to compose. --- .../permissions/PermissionsSettingsEvents.kt | 58 +++- .../PermissionsSettingsFragment.kt | 131 ++------ .../PermissionsSettingsRepository.kt | 138 +++++---- .../permissions/PermissionsSettingsScreen.kt | 241 +++++++++++++++ .../permissions/PermissionsSettingsState.kt | 27 +- .../PermissionsSettingsTestTags.kt | 15 + .../PermissionsSettingsViewModel.kt | 158 +++++----- .../PermissionsSettingsScreenTest.kt | 191 ++++++++++++ .../PermissionsSettingsViewModelTest.kt | 285 +++++++++++------- core/ui/build.gradle.kts | 11 + .../org/signal/core/ui/compose/Dialogs.kt | 121 +++++++- .../java/org/signal/core/ui/compose/Rows.kt | 50 ++- .../core/ui/compose/RadioListDialogTest.kt | 180 +++++++++++ .../core/ui/compose/RadioListRowTest.kt | 122 ++++++++ 14 files changed, 1353 insertions(+), 375 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsTestTags.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreenTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/RadioListDialogTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/RadioListRowTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt index af0faa9a95..d0b19d584c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsEvents.kt @@ -1,8 +1,58 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components.settings.conversation.permissions -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.components.settings.conversation.permissions.PermissionsSettingsRepository.GroupPermissions -sealed class PermissionsSettingsEvents { - class GroupChangeError(val reason: GroupChangeFailureReason) : PermissionsSettingsEvents() - object ShowMemberLabelsWillBeRemovedWarning : PermissionsSettingsEvents() +/** + * Represents everything that can happen on the group permissions screen: the permissions the user picks, plus the + * group's own permissions changing underneath us. + * + * Each permission is expressed as whether non-admins are allowed to do the thing, matching how the rows read. + */ +sealed interface PermissionsSettingsEvents { + + /** + * The group's permissions changed, either because we just changed them or because someone else did. + */ + data class PermissionsChanged(val permissions: GroupPermissions) : PermissionsSettingsEvents + + /** + * User picked who can add new members. + */ + data class SetNonAdminCanAddMembers(val allowed: Boolean) : PermissionsSettingsEvents + + /** + * User picked who can edit the group's name, avatar, and description. + */ + data class SetNonAdminCanEditGroupInfo(val allowed: Boolean) : PermissionsSettingsEvents + + /** + * User picked who can send messages and start calls. Restricting it to admins makes this an announcement group. + */ + data class SetNonAdminCanSendMessages(val allowed: Boolean) : PermissionsSettingsEvents + + /** + * User picked who can add member labels. Restricting it to admins clears the labels non-admins have already set, so + * that direction asks for confirmation first. + */ + data class SetNonAdminCanSetMemberLabel(val allowed: Boolean) : PermissionsSettingsEvents + + /** + * User accepted that restricting member labels to admins will clear the ones non-admins set. + */ + data object MemberLabelsWillBeClearedConfirmed : PermissionsSettingsEvents + + /** + * User dismissed the dialog that was showing. + */ + data object DialogDismissed : PermissionsSettingsEvents + + /** + * The snackbar reporting a rejected change has come and gone. + */ + data object SnackbarDismissed : PermissionsSettingsEvents } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt index 52f4796253..d086754611 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsFragment.kt @@ -1,121 +1,30 @@ package org.thoughtcrime.securesms.components.settings.conversation.permissions -import android.widget.Toast -import androidx.annotation.StringRes -import androidx.fragment.app.viewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.groups.ui.GroupErrors -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.signal.core.ui.compose.ComposeFragment +import org.thoughtcrime.securesms.util.viewModel -class PermissionsSettingsFragment : DSLSettingsFragment( - titleId = R.string.ConversationSettingsFragment__permissions -) { +/** + * Fragment wrapping [PermissionsSettingsScreen] to allow an admin to set which actions non-admins can take in a group. + */ +class PermissionsSettingsFragment : ComposeFragment() { - private val permissionsOptions: Array by lazy { - resources.getStringArray(R.array.PermissionsSettingsFragment__editor_labels) + private val viewModel: PermissionsSettingsViewModel by viewModel { + PermissionsSettingsViewModel(PermissionsSettingsFragmentArgs.fromBundle(requireArguments()).groupId) } - private val viewModel: PermissionsSettingsViewModel by viewModels( - factoryProducer = { - val args = PermissionsSettingsFragmentArgs.fromBundle(requireArguments()) - val repository = PermissionsSettingsRepository(requireContext()) - PermissionsSettingsViewModel.Factory(args.groupId, repository) - } - ) + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() - override fun bindAdapter(adapter: MappingAdapter) { - viewModel.state.observe(viewLifecycleOwner) { state -> - adapter.submitList(getConfiguration(state).toMappingModelList()) - } - - viewModel.events.observe(viewLifecycleOwner) { event -> - when (event) { - is PermissionsSettingsEvents.GroupChangeError -> handleGroupChangeError(event) - is PermissionsSettingsEvents.ShowMemberLabelsWillBeRemovedWarning -> showMemberLabelsWillBeRemovedDialog() + PermissionsSettingsScreen( + state = state, + onEvent = viewModel::onEvent, + onNavigationClick = { + requireActivity().onBackPressedDispatcher.onBackPressed() } - } - } - - private fun handleGroupChangeError(groupChangeError: PermissionsSettingsEvents.GroupChangeError) { - Toast.makeText(context, GroupErrors.getUserDisplayMessage(groupChangeError.reason), Toast.LENGTH_LONG).show() - } - - private fun getConfiguration(state: PermissionsSettingsState): DSLConfiguration { - return configure { - radioListPref( - title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__add_members), - isEnabled = state.selfCanEditSettings, - listItems = permissionsOptions, - dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_add_new_members), - selected = getSelected(state.nonAdminCanAddMembers), - confirmAction = true, - onSelected = { - viewModel.setNonAdminCanAddMembers(it == 1) - } - ) - - radioListPref( - title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__edit_group_info), - isEnabled = state.selfCanEditSettings, - listItems = permissionsOptions, - dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_edit_this_groups_info), - selected = getSelected(state.nonAdminCanEditGroupInfo), - confirmAction = true, - onSelected = { - viewModel.setNonAdminCanEditGroupInfo(it == 1) - } - ) - - radioListPref( - title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__send_messages), - isEnabled = state.selfCanEditSettings, - listItems = permissionsOptions, - dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_send_messages), - selected = getSelected(!state.announcementGroup), - confirmAction = true, - onSelected = { - viewModel.setAnnouncementGroup(it == 0) - } - ) - - radioListPref( - title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__add_member_labels), - isEnabled = state.selfCanEditSettings, - listItems = permissionsOptions, - dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_add_member_labels), - selected = getSelected(state.nonAdminCanSetMemberLabel), - confirmAction = true, - onSelected = { selectedIndex -> - if (selectedIndex >= 0) { - viewModel.onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel = selectedIndex == 1) - } - } - ) - } - } - - private fun showMemberLabelsWillBeRemovedDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.PermissionsSettingsFragment__member_labels_will_be_cleared_title) - .setMessage(R.string.PermissionsSettingsFragment__member_labels_will_be_cleared_body) - .setPositiveButton(R.string.PermissionsSettingsFragment__change_permission) { _, _ -> - viewModel.onRestrictMemberLabelsToAdminsConfirmed() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - @StringRes - private fun getSelected(isNonAdminAllowed: Boolean): Int { - return if (isNonAdminAllowed) { - 1 - } else { - 0 - } + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt index 1aee3d45d7..13c6eeae86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt @@ -1,85 +1,115 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components.settings.conversation.permissions import android.content.Context -import org.signal.core.util.concurrent.SignalExecutors +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.withContext +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log import org.signal.core.util.orNull import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.groups.GroupAccessControl import org.thoughtcrime.securesms.groups.GroupChangeException import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupManager -import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +import org.thoughtcrime.securesms.recipients.Recipient import java.io.IOException -private val TAG = Log.tag(PermissionsSettingsRepository::class.java) +private val TAG = Log.tag(PermissionsSettingsRepository::class) +/** + * All of the group access control reads and writes behind [PermissionsSettingsViewModel]. + */ class PermissionsSettingsRepository( - private val context: Context, + private val context: Context = AppDependencies.application, private val groupTable: GroupTable = SignalDatabase.groups ) { - fun applyMembershipRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) { - SignalExecutors.UNBOUNDED.execute { + /** + * Emits the group's permissions whenever they change. A group change always touches the group's recipient, so + * recipient updates drive this. Plenty of unrelated activity touches it too -- an incoming message refreshes it -- + * hence the dedupe. + */ + fun observePermissions(groupId: GroupId): Flow { + return flow { + val recipientId = withContext(SignalDispatchers.Default) { Recipient.externalGroupExact(groupId).id } + emitAll(Recipient.observable(recipientId).asFlow()) + }.mapNotNull { recipient -> + withContext(SignalDispatchers.Default) { + groupTable.getGroup(recipient.id).orNull()?.toGroupPermissions() + } + }.distinctUntilChanged() + } + + suspend fun applyMembershipRightsChange(groupId: GroupId, newRights: GroupAccessControl): GroupChangeResult = groupChange { + GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights) + } + + suspend fun applyAttributesRightsChange(groupId: GroupId, newRights: GroupAccessControl): GroupChangeResult = groupChange { + GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights) + } + + suspend fun applyAnnouncementGroupChange(groupId: GroupId, isAnnouncementGroup: Boolean): GroupChangeResult = groupChange { + GroupManager.applyAnnouncementGroupChange(context, groupId.requireV2(), isAnnouncementGroup) + } + + suspend fun applyMemberLabelRightsChange(groupId: GroupId, newRights: GroupAccessControl): GroupChangeResult = groupChange { + GroupManager.applyMemberLabelRightsChange(context, groupId.requireV2(), newRights) + } + + /** + * Runs a group change, which hits the network, and translates whatever went wrong into something we can show the + * user. + */ + private suspend fun groupChange(block: () -> Unit): GroupChangeResult { + return withContext(SignalDispatchers.IO) { try { - GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights) + block() + GroupChangeResult.SUCCESS } catch (e: GroupChangeException) { Log.w(TAG, e) - error.onError(GroupChangeFailureReason.fromException(e)) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) } catch (e: IOException) { Log.w(TAG, e) - error.onError(GroupChangeFailureReason.fromException(e)) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) } } } - fun applyAttributesRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) { - SignalExecutors.UNBOUNDED.execute { - try { - GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights) - } catch (e: GroupChangeException) { - Log.w(TAG, e) - error.onError(GroupChangeFailureReason.fromException(e)) - } catch (e: IOException) { - Log.w(TAG, e) - error.onError(GroupChangeFailureReason.fromException(e)) - } - } + private fun GroupRecord.toGroupPermissions(): GroupPermissions { + return GroupPermissions( + selfCanEditSettings = isActive && isAdmin(Recipient.self()), + nonAdminCanAddMembers = membershipAdditionAccessControl == GroupAccessControl.ALL_MEMBERS, + nonAdminCanEditGroupInfo = attributesAccessControl == GroupAccessControl.ALL_MEMBERS, + nonAdminCanSendMessages = !isAnnouncementGroup, + nonAdminCanSetMemberLabel = memberLabelAccessControl == GroupAccessControl.ALL_MEMBERS, + nonAdminsHaveMemberLabels = hasV2GroupProperties && requireV2GroupProperties().nonAdminMembersWithLabels().isNotEmpty() + ) } - fun applyAnnouncementGroupChange(groupId: GroupId, isAnnouncementGroup: Boolean, error: GroupChangeErrorCallback) { - SignalExecutors.UNBOUNDED.execute { - try { - GroupManager.applyAnnouncementGroupChange(context, groupId.requireV2(), isAnnouncementGroup) - } catch (e: GroupChangeException) { - Log.w(TAG, e) - error.onError(GroupChangeFailureReason.fromException(e)) - } catch (e: IOException) { - Log.w(TAG, e) - error.onError(GroupChangeFailureReason.fromException(e)) - } - } - } - - fun hasNonAdminMembersWithLabels(groupId: GroupId): Boolean { - val v2GroupId = groupId.v2OrNull() ?: return false - val group = groupTable.getGroup(v2GroupId).filter { it.hasV2GroupProperties }.orNull() ?: return false - return group.requireV2GroupProperties().nonAdminMembersWithLabels().isNotEmpty() - } - - fun applyMemberLabelRightsChange(groupId: GroupId, newRights: GroupAccessControl, errorCallback: GroupChangeErrorCallback) { - SignalExecutors.UNBOUNDED.execute { - try { - GroupManager.applyMemberLabelRightsChange(context, groupId.requireV2(), newRights) - } catch (e: GroupChangeException) { - Log.w(TAG, e) - errorCallback.onError(GroupChangeFailureReason.fromException(e)) - } catch (e: IOException) { - Log.w(TAG, e) - errorCallback.onError(GroupChangeFailureReason.fromException(e)) - } - } - } + /** + * Everything the permissions screen reads off of the group's record. + */ + data class GroupPermissions( + val selfCanEditSettings: Boolean, + val nonAdminCanAddMembers: Boolean, + val nonAdminCanEditGroupInfo: Boolean, + val nonAdminCanSendMessages: Boolean, + val nonAdminCanSetMemberLabel: Boolean, + val nonAdminsHaveMemberLabels: Boolean + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreen.kt new file mode 100644 index 0000000000..f3e6d2403b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreen.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewWrapper +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Rows.TextAndLabel +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.SignalPreviewWrapper +import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.showSnackbar +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.conversation.permissions.PermissionsSettingsState.Dialog +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupErrors + +/** Row values, in the order [R.array.PermissionsSettingsFragment__editor_labels] declares their labels. */ +private const val VALUE_ONLY_ADMINS = "only_admins" +private const val VALUE_ALL_MEMBERS = "all_members" +private val EDITOR_VALUES = arrayOf(VALUE_ONLY_ADMINS, VALUE_ALL_MEMBERS) + +/** + * Lets a group admin choose which of the group's actions non-admins are allowed to take. + */ +@Composable +fun PermissionsSettingsScreen( + state: PermissionsSettingsState, + onEvent: (PermissionsSettingsEvents) -> Unit, + onNavigationClick: () -> Unit +) { + val editorLabels = stringArrayResource(R.array.PermissionsSettingsFragment__editor_labels) + + Scaffolds.Settings( + title = stringResource(R.string.ConversationSettingsFragment__permissions), + onNavigationClick = onNavigationClick, + navigationIcon = SignalIcons.ArrowStart.imageVector, + navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back), + snackbarHost = { + GroupChangeErrorSnackbarHost( + groupChangeError = state.groupChangeError, + onDismiss = { onEvent(PermissionsSettingsEvents.SnackbarDismissed) } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .testTag(PermissionsSettingsTestTags.CONTENT) + ) { + item { + PermissionRow( + text = stringResource(R.string.PermissionsSettingsFragment__add_members), + dialogTitle = stringResource(R.string.PermissionsSettingsFragment__who_can_add_new_members), + labels = editorLabels, + nonAdminAllowed = state.nonAdminCanAddMembers, + onSelected = { onEvent(PermissionsSettingsEvents.SetNonAdminCanAddMembers(it)) }, + modifier = Modifier.testTag(PermissionsSettingsTestTags.ADD_MEMBERS_ROW), + enabled = state.selfCanEditSettings + ) + } + + item { + PermissionRow( + text = stringResource(R.string.PermissionsSettingsFragment__edit_group_info), + dialogTitle = stringResource(R.string.PermissionsSettingsFragment__who_can_edit_this_groups_info), + labels = editorLabels, + nonAdminAllowed = state.nonAdminCanEditGroupInfo, + onSelected = { onEvent(PermissionsSettingsEvents.SetNonAdminCanEditGroupInfo(it)) }, + modifier = Modifier.testTag(PermissionsSettingsTestTags.EDIT_GROUP_INFO_ROW), + enabled = state.selfCanEditSettings + ) + } + + item { + PermissionRow( + text = stringResource(R.string.PermissionsSettingsFragment__send_messages), + dialogTitle = stringResource(R.string.PermissionsSettingsFragment__who_can_send_messages), + labels = editorLabels, + nonAdminAllowed = state.nonAdminCanSendMessages, + onSelected = { onEvent(PermissionsSettingsEvents.SetNonAdminCanSendMessages(it)) }, + modifier = Modifier.testTag(PermissionsSettingsTestTags.SEND_MESSAGES_ROW), + enabled = state.selfCanEditSettings + ) + } + + item { + PermissionRow( + text = stringResource(R.string.PermissionsSettingsFragment__add_member_labels), + dialogTitle = stringResource(R.string.PermissionsSettingsFragment__who_can_add_member_labels), + labels = editorLabels, + nonAdminAllowed = state.nonAdminCanSetMemberLabel, + onSelected = { onEvent(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(it)) }, + modifier = Modifier.testTag(PermissionsSettingsTestTags.ADD_MEMBER_LABELS_ROW), + enabled = state.selfCanEditSettings + ) + } + } + } + + PermissionsSettingsDialogs( + state = state, + onEvent = onEvent + ) +} + +/** + * A single permission, whose editors are either all members or admins only. + * + * Every one of these is a change to the whole group, so the user has to confirm their choice before we apply it. + */ +@Composable +private fun PermissionRow( + text: String, + dialogTitle: String, + labels: Array, + nonAdminAllowed: Boolean, + onSelected: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Rows.RadioListRow( + text = { selectedIndex -> + TextAndLabel( + text = text, + label = labels.getOrNull(selectedIndex) + ) + }, + dialogTitle = dialogTitle, + labels = labels, + values = EDITOR_VALUES, + selectedValue = if (nonAdminAllowed) VALUE_ALL_MEMBERS else VALUE_ONLY_ADMINS, + onSelected = { onSelected(it == VALUE_ALL_MEMBERS) }, + modifier = modifier, + enabled = enabled, + requireConfirmation = true + ) +} + +/** + * Reports a rejected change, and tells the view model once it's been seen so the next rejection can show. + */ +@Composable +private fun GroupChangeErrorSnackbarHost( + groupChangeError: GroupChangeFailureReason?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val hostState = remember { SnackbarHostState() } + val message = groupChangeError?.let { stringResource(GroupErrors.getUserDisplayMessage(it)) } + + LaunchedEffect(groupChangeError) { + if (message != null) { + hostState.showSnackbar(message = message, duration = Snackbars.Duration.LONG) + onDismiss() + } + } + + Snackbars.Host(hostState, modifier = modifier) +} + +@Composable +private fun PermissionsSettingsDialogs( + state: PermissionsSettingsState, + onEvent: (PermissionsSettingsEvents) -> Unit +) { + when (state.dialog) { + Dialog.MemberLabelsWillBeCleared -> Dialogs.SimpleAlertDialog( + title = stringResource(R.string.PermissionsSettingsFragment__member_labels_will_be_cleared_title), + body = stringResource(R.string.PermissionsSettingsFragment__member_labels_will_be_cleared_body), + confirm = stringResource(R.string.PermissionsSettingsFragment__change_permission), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onEvent(PermissionsSettingsEvents.MemberLabelsWillBeClearedConfirmed) }, + onDismiss = { onEvent(PermissionsSettingsEvents.DialogDismissed) } + ) + + Dialog.None -> Unit + } +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun PermissionsSettingsScreenPreview() { + PermissionsSettingsScreen( + state = PermissionsSettingsState( + selfCanEditSettings = true, + nonAdminCanAddMembers = true, + nonAdminCanSendMessages = true, + nonAdminCanSetMemberLabel = true + ), + onEvent = {}, + onNavigationClick = {} + ) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun PermissionsSettingsScreenNonAdminPreview() { + PermissionsSettingsScreen( + state = PermissionsSettingsState( + selfCanEditSettings = false, + nonAdminCanAddMembers = true, + nonAdminCanSendMessages = true + ), + onEvent = {}, + onNavigationClick = {} + ) +} + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun PermissionsSettingsScreenMemberLabelsDialogPreview() { + PermissionsSettingsScreen( + state = PermissionsSettingsState( + selfCanEditSettings = true, + nonAdminCanSetMemberLabel = true, + nonAdminsHaveMemberLabels = true, + dialog = Dialog.MemberLabelsWillBeCleared + ), + onEvent = {}, + onNavigationClick = {} + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt index 7b53d088b6..e9820c4638 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsState.kt @@ -1,9 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components.settings.conversation.permissions +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason + data class PermissionsSettingsState( val selfCanEditSettings: Boolean = false, val nonAdminCanAddMembers: Boolean = false, val nonAdminCanEditGroupInfo: Boolean = false, - val announcementGroup: Boolean = false, - val nonAdminCanSetMemberLabel: Boolean = false -) + val nonAdminCanSendMessages: Boolean = false, + val nonAdminCanSetMemberLabel: Boolean = false, + /** Whether restricting member labels to admins would actually clear anyone's label. */ + val nonAdminsHaveMemberLabels: Boolean = false, + val dialog: Dialog = Dialog.None, + /** + * Why the last change we submitted came back rejected, if it did. The rows render the group's actual permissions + * rather than what the user asked for, so a rejection needs to say so itself. + */ + val groupChangeError: GroupChangeFailureReason? = null +) { + + sealed interface Dialog { + data object None : Dialog + data object MemberLabelsWillBeCleared : Dialog + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsTestTags.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsTestTags.kt new file mode 100644 index 0000000000..ef4a25dd81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsTestTags.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +object PermissionsSettingsTestTags { + const val CONTENT = "content" + + const val ADD_MEMBERS_ROW = "add_members_row" + const val EDIT_GROUP_INFO_ROW = "edit_group_info_row" + const val SEND_MESSAGES_ROW = "send_messages_row" + const val ADD_MEMBER_LABELS_ROW = "add_member_labels_row" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt index c844d40a57..d7213085d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModel.kt @@ -1,83 +1,110 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components.settings.conversation.permissions -import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.conversation.permissions.PermissionsSettingsState.Dialog import org.thoughtcrime.securesms.groups.GroupAccessControl import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.groups.LiveGroup -import org.thoughtcrime.securesms.util.SingleLiveEvent -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil -import org.thoughtcrime.securesms.util.livedata.Store +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +/** + * View model behind [PermissionsSettingsScreen]. + */ class PermissionsSettingsViewModel( private val groupId: GroupId, - private val repository: PermissionsSettingsRepository, - liveGroup: LiveGroup = LiveGroup(groupId) -) : ViewModel() { + private val repository: PermissionsSettingsRepository = PermissionsSettingsRepository() +) : EventDrivenViewModel(TAG) { - private val store = Store(PermissionsSettingsState()) - private val internalEvents = SingleLiveEvent() + companion object { + private val TAG = Log.tag(PermissionsSettingsViewModel::class) + } - val state: LiveData = store.stateLiveData - val events: LiveData = internalEvents + private val _state = MutableStateFlow(PermissionsSettingsState()) + + val state: StateFlow = _state.asStateFlow() init { - store.update(LiveDataUtil.combineLatest(liveGroup.isSelfAdmin, liveGroup.isActive) { admin, active -> admin && active }) { canEdit, state -> - state.copy(selfCanEditSettings = canEdit) - } + repository + .observePermissions(groupId) + .onEach { onEvent(PermissionsSettingsEvents.PermissionsChanged(it)) } + .launchIn(viewModelScope) + } - store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state -> - state.copy(nonAdminCanAddMembers = membershipAdditionAccessControl == GroupAccessControl.ALL_MEMBERS) - } + override suspend fun processEvent(event: PermissionsSettingsEvents) { + when (event) { + is PermissionsSettingsEvents.PermissionsChanged -> { + _state.update { + it.copy( + selfCanEditSettings = event.permissions.selfCanEditSettings, + nonAdminCanAddMembers = event.permissions.nonAdminCanAddMembers, + nonAdminCanEditGroupInfo = event.permissions.nonAdminCanEditGroupInfo, + nonAdminCanSendMessages = event.permissions.nonAdminCanSendMessages, + nonAdminCanSetMemberLabel = event.permissions.nonAdminCanSetMemberLabel, + nonAdminsHaveMemberLabels = event.permissions.nonAdminsHaveMemberLabels + ) + } + } - store.update(liveGroup.attributesAccessControl) { attributesAccessControl, state -> - state.copy(nonAdminCanEditGroupInfo = attributesAccessControl == GroupAccessControl.ALL_MEMBERS) - } + is PermissionsSettingsEvents.SetNonAdminCanAddMembers -> { + applyChange { repository.applyMembershipRightsChange(groupId, event.allowed.asGroupAccessControl()) } + } - store.update(liveGroup.isAnnouncementGroup) { isAnnouncementGroup, state -> - state.copy(announcementGroup = isAnnouncementGroup) - } + is PermissionsSettingsEvents.SetNonAdminCanEditGroupInfo -> { + applyChange { repository.applyAttributesRightsChange(groupId, event.allowed.asGroupAccessControl()) } + } - store.update(liveGroup.memberLabelAccessControl) { memberLabelAccessControl, state -> - state.copy(nonAdminCanSetMemberLabel = memberLabelAccessControl == GroupAccessControl.ALL_MEMBERS) + is PermissionsSettingsEvents.SetNonAdminCanSendMessages -> { + applyChange { repository.applyAnnouncementGroupChange(groupId, isAnnouncementGroup = !event.allowed) } + } + + is PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel -> { + if (!event.allowed && _state.value.nonAdminsHaveMemberLabels) { + _state.update { it.copy(dialog = Dialog.MemberLabelsWillBeCleared) } + } else { + applyChange { repository.applyMemberLabelRightsChange(groupId, event.allowed.asGroupAccessControl()) } + } + } + + PermissionsSettingsEvents.MemberLabelsWillBeClearedConfirmed -> { + _state.update { it.copy(dialog = Dialog.None) } + applyChange { repository.applyMemberLabelRightsChange(groupId, GroupAccessControl.ONLY_ADMINS) } + } + + PermissionsSettingsEvents.DialogDismissed -> { + _state.update { it.copy(dialog = Dialog.None) } + } + + PermissionsSettingsEvents.SnackbarDismissed -> { + _state.update { it.copy(groupChangeError = null) } + } } } - fun setNonAdminCanAddMembers(nonAdminCanAddMembers: Boolean) { - repository.applyMembershipRightsChange(groupId, nonAdminCanAddMembers.asGroupAccessControl()) { reason -> - internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason)) - } - } + /** + * Submits a group change without waiting for it, since events are processed one at a time and a change takes a + * network round trip. Waiting would leave whatever the user does next -- another permission, or the dialog that + * warns them about clearing member labels -- sitting in the queue looking like it did nothing. + */ + private fun applyChange(change: suspend () -> GroupChangeResult) { + viewModelScope.launch { + val result = change() - fun setNonAdminCanEditGroupInfo(nonAdminCanEditGroupInfo: Boolean) { - repository.applyAttributesRightsChange(groupId, nonAdminCanEditGroupInfo.asGroupAccessControl()) { reason -> - internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason)) - } - } - - fun setAnnouncementGroup(announcementGroup: Boolean) { - repository.applyAnnouncementGroupChange(groupId, announcementGroup) { reason -> - internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason)) - } - } - - fun onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel: Boolean) { - if (!nonAdminCanSetMemberLabel && repository.hasNonAdminMembersWithLabels(groupId)) { - internalEvents.postValue(PermissionsSettingsEvents.ShowMemberLabelsWillBeRemovedWarning) - } else { - setNonAdminCanSetMemberLabel(nonAdminCanSetMemberLabel) - } - } - - fun onRestrictMemberLabelsToAdminsConfirmed() = setNonAdminCanSetMemberLabel(false) - - private fun setNonAdminCanSetMemberLabel(nonAdminCanSetMemberLabel: Boolean) { - repository.applyMemberLabelRightsChange( - groupId = groupId, - newRights = nonAdminCanSetMemberLabel.asGroupAccessControl() - ) { failureReason -> - internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(failureReason)) + if (!result.isSuccess) { + _state.update { it.copy(groupChangeError = result.failureReason) } + } } } @@ -88,13 +115,4 @@ class PermissionsSettingsViewModel( GroupAccessControl.ONLY_ADMINS } } - - class Factory( - private val groupId: GroupId, - private val repository: PermissionsSettingsRepository - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return requireNotNull(modelClass.cast(PermissionsSettingsViewModel(groupId, repository))) - } - } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreenTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreenTest.kt new file mode 100644 index 0000000000..c42969363d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsScreenTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.permissions + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.components.settings.conversation.permissions.PermissionsSettingsState.Dialog +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupErrors + +/** + * Checks which events the permission rows emit, and that they only emit them for an admin of an active group. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class PermissionsSettingsScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + @Test + fun `allowing all members to add members emits SetNonAdminCanAddMembers`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true)) + + click(PermissionsSettingsTestTags.ADD_MEMBERS_ROW) + selectEditor(allMembers = true) + + assertThat(events).containsExactly(PermissionsSettingsEvents.SetNonAdminCanAddMembers(true)) + } + + @Test + fun `restricting members from adding members emits SetNonAdminCanAddMembers`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true, nonAdminCanAddMembers = true)) + + click(PermissionsSettingsTestTags.ADD_MEMBERS_ROW) + selectEditor(allMembers = false) + + assertThat(events).containsExactly(PermissionsSettingsEvents.SetNonAdminCanAddMembers(false)) + } + + @Test + fun `picking who can edit group info emits SetNonAdminCanEditGroupInfo`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true)) + + click(PermissionsSettingsTestTags.EDIT_GROUP_INFO_ROW) + selectEditor(allMembers = true) + + assertThat(events).containsExactly(PermissionsSettingsEvents.SetNonAdminCanEditGroupInfo(true)) + } + + @Test + fun `picking who can send messages emits SetNonAdminCanSendMessages`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true, nonAdminCanSendMessages = true)) + + click(PermissionsSettingsTestTags.SEND_MESSAGES_ROW) + selectEditor(allMembers = false) + + assertThat(events).containsExactly(PermissionsSettingsEvents.SetNonAdminCanSendMessages(false)) + } + + @Test + fun `picking who can add member labels emits SetNonAdminCanSetMemberLabel`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true, nonAdminCanSetMemberLabel = true)) + + click(PermissionsSettingsTestTags.ADD_MEMBER_LABELS_ROW) + selectEditor(allMembers = false) + + assertThat(events).containsExactly(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(false)) + } + + @Test + fun `picking an option without confirming it changes nothing`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true)) + + click(PermissionsSettingsTestTags.ADD_MEMBERS_ROW) + composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(1)).performClick() + + assertThat(events).isEmpty() + } + + @Test + fun `no row does anything for someone who cannot edit settings`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = false)) + + click(PermissionsSettingsTestTags.ADD_MEMBERS_ROW) + click(PermissionsSettingsTestTags.EDIT_GROUP_INFO_ROW) + click(PermissionsSettingsTestTags.SEND_MESSAGES_ROW) + click(PermissionsSettingsTestTags.ADD_MEMBER_LABELS_ROW) + + assertThat(events).isEmpty() + } + + @Test + fun `confirming the member labels warning emits MemberLabelsWillBeClearedConfirmed`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true, dialog = Dialog.MemberLabelsWillBeCleared)) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + + assertThat(events).containsExactly( + PermissionsSettingsEvents.DialogDismissed, + PermissionsSettingsEvents.MemberLabelsWillBeClearedConfirmed + ) + } + + @Test + fun `dismissing the member labels warning emits DialogDismissed`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true, dialog = Dialog.MemberLabelsWillBeCleared)) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick() + + assertThat(events).containsExactly(PermissionsSettingsEvents.DialogDismissed) + } + + @Test + fun `a rejected change is reported in a snackbar, which emits SnackbarDismissed once it has been seen`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true, groupChangeError = GroupChangeFailureReason.NO_RIGHTS)) + + composeTestRule.onNodeWithText(failureMessage(GroupChangeFailureReason.NO_RIGHTS)).assertIsDisplayed() + + composeTestRule.waitUntil(timeoutMillis = 10_000) { events.isNotEmpty() } + + assertThat(events).containsExactly(PermissionsSettingsEvents.SnackbarDismissed) + } + + @Test + fun `no snackbar shows while nothing has been rejected`() { + val events = setContent(PermissionsSettingsState(selfCanEditSettings = true)) + + composeTestRule.onNodeWithText(failureMessage(GroupChangeFailureReason.NO_RIGHTS)).assertDoesNotExist() + + assertThat(events).isEmpty() + } + + private fun setContent(state: PermissionsSettingsState): List { + val events = mutableListOf() + + composeTestRule.setContent { + SignalTheme { + PermissionsSettingsScreen( + state = state, + onEvent = { events += it }, + onNavigationClick = {} + ) + } + } + + return events + } + + private fun failureMessage(reason: GroupChangeFailureReason): String { + return ApplicationProvider.getApplicationContext().getString(GroupErrors.getUserDisplayMessage(reason)) + } + + private fun click(tag: String) { + composeTestRule.onNodeWithTag(PermissionsSettingsTestTags.CONTENT).performScrollToNode(hasTestTag(tag)) + composeTestRule.onNodeWithTag(tag).performClick() + } + + /** + * Picks an option out of an open editor dialog, whose options are ordered admins-only first, and confirms it. Every + * one of these rows makes the user confirm before the change is applied. + */ + private fun selectEditor(allMembers: Boolean) { + composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(if (allMembers) 1 else 0)).performClick() + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON).performClick() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt index 899a1395eb..aea680c520 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsViewModelTest.kt @@ -5,134 +5,189 @@ package org.thoughtcrime.securesms.components.settings.conversation.permissions -import androidx.lifecycle.MutableLiveData +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify -import org.junit.Assert.assertEquals -import org.junit.Rule +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before import org.junit.Test +import org.thoughtcrime.securesms.components.settings.conversation.permissions.PermissionsSettingsRepository.GroupPermissions +import org.thoughtcrime.securesms.components.settings.conversation.permissions.PermissionsSettingsState.Dialog import org.thoughtcrime.securesms.groups.GroupAccessControl import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.groups.LiveGroup -import org.thoughtcrime.securesms.util.livedata.LiveDataRule -import org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +@OptIn(ExperimentalCoroutinesApi::class) class PermissionsSettingsViewModelTest { - @get:Rule - val liveDataRule = LiveDataRule() + + private val testDispatcher = StandardTestDispatcher() private val groupId = mockk() - private val repository = mockk(relaxUnitFun = true) + private val repository = mockk() - private fun createViewModel( - memberLabelAccessControl: GroupAccessControl = GroupAccessControl.ONLY_ADMINS, - nonAdminMembersHaveLabels: Boolean = true - ): PermissionsSettingsViewModel { - val liveGroup = mockk { - every { isSelfAdmin } returns MutableLiveData(false) - every { isActive } returns MutableLiveData(true) - every { membershipAdditionAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS) - every { attributesAccessControl } returns MutableLiveData(GroupAccessControl.ONLY_ADMINS) - every { isAnnouncementGroup } returns MutableLiveData(false) - every { this@mockk.memberLabelAccessControl } returns MutableLiveData(memberLabelAccessControl) - } + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) - every { repository.hasNonAdminMembersWithLabels(groupId) } returns nonAdminMembersHaveLabels + coEvery { repository.applyMembershipRightsChange(any(), any()) } returns GroupChangeResult.SUCCESS + coEvery { repository.applyMemberLabelRightsChange(any(), any()) } returns GroupChangeResult.SUCCESS + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the group's permissions drive the state`() = runTest(testDispatcher) { + val viewModel = createViewModel(nonAdminsHaveMemberLabels = false) + + assertThat(viewModel.state.value).isEqualTo( + PermissionsSettingsState( + selfCanEditSettings = true, + nonAdminCanAddMembers = true, + nonAdminCanEditGroupInfo = true, + nonAdminCanSendMessages = true, + nonAdminCanSetMemberLabel = true, + nonAdminsHaveMemberLabels = false + ) + ) + } + + @Test + fun `restricting member labels to admins applies immediately when no non-admin has a label`() = runTest(testDispatcher) { + val viewModel = createViewModel(nonAdminsHaveMemberLabels = false) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(false)) + advanceUntilIdle() + + coVerify { repository.applyMemberLabelRightsChange(groupId, GroupAccessControl.ONLY_ADMINS) } + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + } + + @Test + fun `restricting member labels to admins warns first when a non-admin has a label`() = runTest(testDispatcher) { + val viewModel = createViewModel(nonAdminsHaveMemberLabels = true) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(false)) + advanceUntilIdle() + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MemberLabelsWillBeCleared) + coVerify(exactly = 0) { repository.applyMemberLabelRightsChange(any(), any()) } + } + + @Test + fun `opening member labels up to all members never warns`() = runTest(testDispatcher) { + val viewModel = createViewModel(nonAdminsHaveMemberLabels = true) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(true)) + advanceUntilIdle() + + coVerify { repository.applyMemberLabelRightsChange(groupId, GroupAccessControl.ALL_MEMBERS) } + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + } + + @Test + fun `confirming the warning restricts member labels to admins`() = runTest(testDispatcher) { + val viewModel = createViewModel(nonAdminsHaveMemberLabels = true) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(false)) + viewModel.onEvent(PermissionsSettingsEvents.MemberLabelsWillBeClearedConfirmed) + advanceUntilIdle() + + coVerify { repository.applyMemberLabelRightsChange(groupId, GroupAccessControl.ONLY_ADMINS) } + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + } + + @Test + fun `dismissing the warning leaves the permission alone`() = runTest(testDispatcher) { + val viewModel = createViewModel(nonAdminsHaveMemberLabels = true) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(false)) + viewModel.onEvent(PermissionsSettingsEvents.DialogDismissed) + advanceUntilIdle() + + coVerify(exactly = 0) { repository.applyMemberLabelRightsChange(any(), any()) } + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + } + + @Test + fun `a change still in flight does not hold up the next event`() = runTest(testDispatcher) { + val inFlight = CompletableDeferred() + coEvery { repository.applyMembershipRightsChange(any(), any()) } coAnswers { inFlight.await() } + + val viewModel = createViewModel(nonAdminsHaveMemberLabels = true) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanAddMembers(true)) + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanSetMemberLabel(false)) + advanceUntilIdle() + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MemberLabelsWillBeCleared) + + inFlight.complete(GroupChangeResult.SUCCESS) + advanceUntilIdle() + } + + @Test + fun `a rejected change surfaces its failure reason`() = runTest(testDispatcher) { + coEvery { repository.applyMembershipRightsChange(any(), any()) } returns GroupChangeResult.failure(GroupChangeFailureReason.NO_RIGHTS) + + val viewModel = createViewModel(nonAdminsHaveMemberLabels = false) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanAddMembers(false)) + advanceUntilIdle() + + assertThat(viewModel.state.value.groupChangeError).isEqualTo(GroupChangeFailureReason.NO_RIGHTS) + } + + @Test + fun `a failure reason is cleared once its snackbar has been seen`() = runTest(testDispatcher) { + coEvery { repository.applyMembershipRightsChange(any(), any()) } returns GroupChangeResult.failure(GroupChangeFailureReason.NO_RIGHTS) + + val viewModel = createViewModel(nonAdminsHaveMemberLabels = false) + + viewModel.onEvent(PermissionsSettingsEvents.SetNonAdminCanAddMembers(false)) + advanceUntilIdle() + viewModel.onEvent(PermissionsSettingsEvents.SnackbarDismissed) + advanceUntilIdle() + + assertThat(viewModel.state.value.groupChangeError).isNull() + } + + /** + * Builds a view model and lets the group's permissions land before returning it, since the screen keeps every row + * disabled until they do. + */ + private fun TestScope.createViewModel(nonAdminsHaveMemberLabels: Boolean): PermissionsSettingsViewModel { + every { repository.observePermissions(groupId) } returns flowOf( + GroupPermissions( + selfCanEditSettings = true, + nonAdminCanAddMembers = true, + nonAdminCanEditGroupInfo = true, + nonAdminCanSendMessages = true, + nonAdminCanSetMemberLabel = true, + nonAdminsHaveMemberLabels = nonAdminsHaveMemberLabels + ) + ) return PermissionsSettingsViewModel( groupId = groupId, - repository = repository, - liveGroup = liveGroup - ) - } - - @Test - fun `onMemberLabelPermissionChangeRequested immediately applies 'only admins' change when there are no non-admin members with labels`() { - val viewModel = createViewModel( - memberLabelAccessControl = GroupAccessControl.ALL_MEMBERS, - nonAdminMembersHaveLabels = false - ) - - viewModel.onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel = false) - - verify { - repository.applyMemberLabelRightsChange( - groupId = groupId, - newRights = GroupAccessControl.ONLY_ADMINS, - errorCallback = any() - ) - } - LiveDataTestUtil.assertNoValue(viewModel.events) - } - - @Test - fun `onMemberLabelPermissionChangeRequested immediately applies 'all members' change when there are no non-admin members with labels`() { - val viewModel = createViewModel( - memberLabelAccessControl = GroupAccessControl.ONLY_ADMINS, - nonAdminMembersHaveLabels = false - ) - - viewModel.onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel = true) - - verify { - repository.applyMemberLabelRightsChange( - groupId = groupId, - newRights = GroupAccessControl.ALL_MEMBERS, - errorCallback = any() - ) - } - LiveDataTestUtil.assertNoValue(viewModel.events) - } - - @Test - fun `onMemberLabelPermissionChangeRequested displays warning when restricting to 'only admins' and some non-admin members have labels`() { - val viewModel = createViewModel( - memberLabelAccessControl = GroupAccessControl.ALL_MEMBERS, - nonAdminMembersHaveLabels = true - ) - - viewModel.onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel = false) - - assertEquals( - PermissionsSettingsEvents.ShowMemberLabelsWillBeRemovedWarning, - LiveDataTestUtil.observeAndGetOneValue(viewModel.events) - ) - - verify(exactly = 0) { - repository.applyMemberLabelRightsChange(any(), any(), any()) - } - } - - @Test - fun `onMemberLabelPermissionChangeRequested immediately applies 'all members' change when some non-admin members have labels`() { - val viewModel = createViewModel(memberLabelAccessControl = GroupAccessControl.ALL_MEMBERS, nonAdminMembersHaveLabels = true) - - viewModel.onMemberLabelPermissionChangeRequested(nonAdminCanSetMemberLabel = true) - - verify { - repository.applyMemberLabelRightsChange( - groupId = groupId, - newRights = GroupAccessControl.ALL_MEMBERS, - errorCallback = any() - ) - } - LiveDataTestUtil.assertNoValue(viewModel.events) - } - - @Test - fun `onRestrictMemberLabelsToAdminsConfirmed applies 'only admins' change`() { - val viewModel = createViewModel(memberLabelAccessControl = GroupAccessControl.ALL_MEMBERS, nonAdminMembersHaveLabels = true) - - viewModel.onRestrictMemberLabelsToAdminsConfirmed() - - verify { - repository.applyMemberLabelRightsChange( - groupId = groupId, - newRights = GroupAccessControl.ONLY_ADMINS, - errorCallback = any() - ) - } - LiveDataTestUtil.assertNoValue(viewModel.events) + repository = repository + ).also { advanceUntilIdle() } } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 25d202fa83..a0b6465264 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -14,6 +14,12 @@ android { testFixtures { enable = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -41,8 +47,13 @@ dependencies { api(libs.accompanist.permissions) testImplementation(testLibs.junit.junit) + testImplementation(testLibs.assertk) testImplementation(testLibs.kotlinx.coroutines.test) testImplementation(testLibs.robolectric.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + + // Supplies the ComponentActivity that createComposeRule() launches the content into + debugImplementation(libs.androidx.compose.ui.test.manifest) // JUnit is used by test fixtures testFixturesImplementation(testLibs.junit.junit) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index 065b01903d..7b2d1d58ec 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -55,6 +56,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -84,6 +86,9 @@ object Dialogs { /** Suffixed with the index of the option the row renders. */ const val TEST_TAG_RADIO_LIST_DIALOG_OPTION = "dialog-radio-list-option" + const val TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON = "dialog-radio-list-confirm-button" + const val TEST_TAG_RADIO_LIST_DIALOG_DISMISS_BUTTON = "dialog-radio-list-dismiss-button" + fun testTagRadioListDialogOption(index: Int) = "$TEST_TAG_RADIO_LIST_DIALOG_OPTION:$index" object Defaults { @@ -551,6 +556,9 @@ object Dialogs { } } + /** + * Radio list whose choice takes effect as soon as the user taps it. + */ @Composable fun RadioListDialog( onDismissRequest: () -> Unit, @@ -560,6 +568,85 @@ object Dialogs { values: Array, selectedIndex: Int, onSelected: (Int) -> Unit + ) { + RadioList( + onDismissRequest = onDismissRequest, + properties = properties, + title = title, + labels = labels, + values = values, + selectedIndex = selectedIndex, + onOptionClick = { index -> + onSelected(index) + onDismissRequest() + } + ) + } + + /** + * Radio list whose choice only takes effect once the user confirms it, for changes worth guarding against a + * mistaken tap. Dismissing the dialog leaves the original selection alone. + */ + @Composable + fun RadioListConfirmationDialog( + onDismissRequest: () -> Unit, + title: String, + labels: Array, + values: Array, + selectedIndex: Int, + onConfirm: (Int) -> Unit, + properties: DialogProperties = DialogProperties(), + confirm: String = stringResource(R.string.ok), + dismiss: String = stringResource(R.string.cancel) + ) { + // Deliberately unkeyed: the caller's selection can change underneath an open dialog, and the user's own pick + // should survive that. Dismissing takes the dialog out of composition, so the next one starts fresh. + var pendingIndex by remember { mutableIntStateOf(selectedIndex) } + + RadioList( + onDismissRequest = onDismissRequest, + properties = properties, + title = title, + labels = labels, + values = values, + selectedIndex = pendingIndex, + onOptionClick = { pendingIndex = it }, + buttons = { + TextButton( + onClick = onDismissRequest, + modifier = Modifier.testTag(TEST_TAG_RADIO_LIST_DIALOG_DISMISS_BUTTON) + ) { + Text(text = dismiss) + } + + TextButton( + onClick = { + onConfirm(pendingIndex) + onDismissRequest() + }, + modifier = Modifier.testTag(TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON), + enabled = pendingIndex in values.indices + ) { + Text(text = confirm) + } + } + ) + } + + /** + * The list itself, shared by both radio list dialogs. [buttons] is laid out below the options, for the variants that + * have any. + */ + @Composable + private fun RadioList( + onDismissRequest: () -> Unit, + properties: DialogProperties, + title: String, + labels: Array, + values: Array, + selectedIndex: Int, + onOptionClick: (Int) -> Unit, + buttons: (@Composable () -> Unit)? = null ) { Dialog( onDismissRequest = onDismissRequest, @@ -584,7 +671,9 @@ object Dialogs { ) LazyColumn( - modifier = Modifier.padding(top = 24.dp, bottom = 16.dp), + modifier = Modifier + .weight(1f, fill = false) + .padding(top = 24.dp, bottom = 16.dp), state = rememberLazyListState( initialFirstVisibleItemIndex = max(selectedIndex, 0) ) @@ -600,10 +689,7 @@ object Dialogs { .defaultMinSize(minHeight = 48.dp) .clickable( enabled = true, - onClick = { - onSelected(index) - onDismissRequest() - } + onClick = { onOptionClick(index) } ) .horizontalGutters() .testTag(testTagRadioListDialogOption(index)) @@ -619,6 +705,17 @@ object Dialogs { } } } + + if (buttons != null) { + FlowRow( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + buttons() + } + } } } } @@ -948,3 +1045,17 @@ private fun RadioListDialogPreview() { ) } } + +@PreviewWrapper(SignalPreviewWrapper::class) +@DayNightPreviews +@Composable +private fun RadioListConfirmationDialogPreview() { + Dialogs.RadioListConfirmationDialog( + onDismissRequest = {}, + title = "TestDialog", + labels = arrayOf("Only admins", "All members"), + values = arrayOf("only_admins", "all_members"), + selectedIndex = 0, + onConfirm = {} + ) +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt index cf731f3f82..a635ed27fb 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt @@ -146,6 +146,10 @@ object Rows { } } + /** + * @param requireConfirmation Whether the dialog's choice only takes effect once the user confirms it, rather than as + * soon as they tap it. + */ @Composable fun RadioListRow( text: String, @@ -154,7 +158,8 @@ object Rows { selectedValue: String, onSelected: (String) -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true + enabled: Boolean = true, + requireConfirmation: Boolean = false ) { RadioListRow( text = { selectedIndex -> @@ -175,10 +180,15 @@ object Rows { selectedValue = selectedValue, onSelected = onSelected, modifier = modifier, - enabled = enabled + enabled = enabled, + requireConfirmation = requireConfirmation ) } + /** + * @param requireConfirmation Whether the dialog's choice only takes effect once the user confirms it, rather than as + * soon as they tap it. + */ @Composable fun RadioListRow( text: @Composable RowScope.(Int) -> Unit, @@ -188,7 +198,8 @@ object Rows { selectedValue: String, onSelected: (String) -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true + enabled: Boolean = true, + requireConfirmation: Boolean = false ) { val selectedIndex = values.indexOf(selectedValue) var displayDialog by remember { mutableStateOf(false) } @@ -203,16 +214,29 @@ object Rows { ) if (displayDialog) { - Dialogs.RadioListDialog( - onDismissRequest = { displayDialog = false }, - labels = labels, - values = values, - selectedIndex = selectedIndex, - title = dialogTitle, - onSelected = { - onSelected(values[it]) - } - ) + if (requireConfirmation) { + Dialogs.RadioListConfirmationDialog( + onDismissRequest = { displayDialog = false }, + labels = labels, + values = values, + selectedIndex = selectedIndex, + title = dialogTitle, + onConfirm = { + onSelected(values[it]) + } + ) + } else { + Dialogs.RadioListDialog( + onDismissRequest = { displayDialog = false }, + labels = labels, + values = values, + selectedIndex = selectedIndex, + title = dialogTitle, + onSelected = { + onSelected(values[it]) + } + ) + } } } diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/RadioListDialogTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/RadioListDialogTest.kt new file mode 100644 index 0000000000..276526bf78 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/RadioListDialogTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import android.app.Application +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.theme.SignalTheme + +/** + * Checks when each radio list dialog reports a choice: the plain one as soon as an option is tapped, the confirmation + * one only once the user says so. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class RadioListDialogTest { + + companion object { + private val LABELS = arrayOf("A", "B", "C") + private val VALUES = arrayOf("a", "b", "c") + } + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + private val selections = mutableListOf() + private var dismissRequests = 0 + + @Test + fun `the plain dialog reports a tapped option immediately`() { + setContent { + Dialogs.RadioListDialog( + onDismissRequest = { dismissRequests++ }, + title = "Title", + labels = LABELS, + values = VALUES, + selectedIndex = 0, + onSelected = { selections += it } + ) + } + + clickOption(2) + + assertThat(selections).containsExactly(2) + assertThat(dismissRequests).isEqualTo(1) + } + + @Test + fun `the confirmation dialog reports nothing until it is confirmed`() { + setConfirmationContent(selectedIndex = 0) + + clickOption(1) + clickOption(2) + + assertThat(selections).isEmpty() + assertThat(dismissRequests).isEqualTo(0) + } + + @Test + fun `confirming reports the option that was tapped last`() { + setConfirmationContent(selectedIndex = 0) + + clickOption(1) + clickOption(2) + confirm() + + assertThat(selections).containsExactly(2) + assertThat(dismissRequests).isEqualTo(1) + } + + @Test + fun `confirming without tapping anything reports the option that was already selected`() { + setConfirmationContent(selectedIndex = 1) + + confirm() + + assertThat(selections).containsExactly(1) + } + + @Test + fun `dismissing the confirmation dialog leaves the selection alone`() { + setConfirmationContent(selectedIndex = 0) + + clickOption(2) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_RADIO_LIST_DIALOG_DISMISS_BUTTON).performClick() + + assertThat(selections).isEmpty() + assertThat(dismissRequests).isEqualTo(1) + } + + @Test + fun `a pick in the confirmation dialog survives the caller's selection changing underneath it`() { + var callerIndex by mutableStateOf(0) + + setContent { + Dialogs.RadioListConfirmationDialog( + onDismissRequest = { dismissRequests++ }, + title = "Title", + labels = LABELS, + values = VALUES, + selectedIndex = callerIndex, + onConfirm = { selections += it } + ) + } + + clickOption(2) + callerIndex = 1 + composeTestRule.waitForIdle() + confirm() + + assertThat(selections).containsExactly(2) + } + + @Test + fun `a confirmation dialog without a valid selection cannot be confirmed until one is made`() { + setConfirmationContent(selectedIndex = -1) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON).assertIsNotEnabled() + + clickOption(1) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON).assertIsEnabled() + confirm() + + assertThat(selections).containsExactly(1) + } + + private fun setConfirmationContent(selectedIndex: Int) { + setContent { + Dialogs.RadioListConfirmationDialog( + onDismissRequest = { dismissRequests++ }, + title = "Title", + labels = LABELS, + values = VALUES, + selectedIndex = selectedIndex, + onConfirm = { selections += it } + ) + } + } + + private fun setContent(content: @Composable () -> Unit) { + composeTestRule.setContent { + SignalTheme { + content() + } + } + } + + private fun clickOption(index: Int) { + composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(index)).performClick() + } + + private fun confirm() { + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON).performClick() + } +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/RadioListRowTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/RadioListRowTest.kt new file mode 100644 index 0000000000..59f05a8dc6 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/RadioListRowTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import android.app.Application +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.theme.SignalTheme + +/** + * Checks how [Rows.RadioListRow] hands its choice back, with and without the confirmation step. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class RadioListRowTest { + + companion object { + private const val ROW = "row" + + private val LABELS = arrayOf("A", "B", "C") + private val VALUES = arrayOf("a", "b", "c") + } + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + private val selections = mutableListOf() + + @Test + fun `a plain row reports the option that was tapped`() { + setContent(requireConfirmation = false) + + composeTestRule.onNodeWithTag(ROW).performClick() + clickOption(2) + + assertThat(selections).containsExactly("c") + } + + @Test + fun `a confirmation row reports nothing until it is confirmed`() { + setContent(requireConfirmation = true) + + composeTestRule.onNodeWithTag(ROW).performClick() + clickOption(2) + + assertThat(selections).isEmpty() + } + + @Test + fun `a confirmation row reports the option that was confirmed`() { + setContent(requireConfirmation = true) + + composeTestRule.onNodeWithTag(ROW).performClick() + clickOption(2) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON).performClick() + + assertThat(selections).containsExactly("c") + } + + @Test + fun `confirming closes the dialog`() { + setContent(requireConfirmation = true) + + composeTestRule.onNodeWithTag(ROW).performClick() + composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(1)).assertIsDisplayed() + + clickOption(1) + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_RADIO_LIST_DIALOG_CONFIRM_BUTTON).performClick() + + composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(1)).assertDoesNotExist() + } + + @Test + fun `a disabled row does not open its dialog`() { + setContent(requireConfirmation = true, enabled = false) + + composeTestRule.onNodeWithTag(ROW).performClick() + + composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(0)).assertDoesNotExist() + } + + private fun setContent(requireConfirmation: Boolean, enabled: Boolean = true) { + composeTestRule.setContent { + SignalTheme { + Rows.RadioListRow( + text = "Radio List", + labels = LABELS, + values = VALUES, + selectedValue = "a", + onSelected = { selections += it }, + modifier = Modifier.testTag(ROW), + enabled = enabled, + requireConfirmation = requireConfirmation + ) + } + } + } + + private fun clickOption(index: Int) { + composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(index)).performClick() + } +}