mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-19 16:24:41 +01:00
Convert group permissions page to compose.
This commit is contained in:
+54
-4
@@ -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
|
||||
}
|
||||
|
||||
+20
-111
@@ -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<String> 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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+84
-54
@@ -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<GroupPermissions> {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
+241
@@ -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<String>,
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
+24
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
+88
-70
@@ -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<PermissionsSettingsEvents>(TAG) {
|
||||
|
||||
private val store = Store(PermissionsSettingsState())
|
||||
private val internalEvents = SingleLiveEvent<PermissionsSettingsEvents>()
|
||||
companion object {
|
||||
private val TAG = Log.tag(PermissionsSettingsViewModel::class)
|
||||
}
|
||||
|
||||
val state: LiveData<PermissionsSettingsState> = store.stateLiveData
|
||||
val events: LiveData<PermissionsSettingsEvents> = internalEvents
|
||||
private val _state = MutableStateFlow(PermissionsSettingsState())
|
||||
|
||||
val state: StateFlow<PermissionsSettingsState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(PermissionsSettingsViewModel(groupId, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+191
@@ -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<PermissionsSettingsEvents> {
|
||||
val events = mutableListOf<PermissionsSettingsEvents>()
|
||||
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
PermissionsSettingsScreen(
|
||||
state = state,
|
||||
onEvent = { events += it },
|
||||
onNavigationClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
private fun failureMessage(reason: GroupChangeFailureReason): String {
|
||||
return ApplicationProvider.getApplicationContext<Application>().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()
|
||||
}
|
||||
}
|
||||
+170
-115
@@ -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<GroupId.V2>()
|
||||
private val repository = mockk<PermissionsSettingsRepository>(relaxUnitFun = true)
|
||||
private val repository = mockk<PermissionsSettingsRepository>()
|
||||
|
||||
private fun createViewModel(
|
||||
memberLabelAccessControl: GroupAccessControl = GroupAccessControl.ONLY_ADMINS,
|
||||
nonAdminMembersHaveLabels: Boolean = true
|
||||
): PermissionsSettingsViewModel {
|
||||
val liveGroup = mockk<LiveGroup> {
|
||||
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<GroupChangeResult>()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
values: Array<String>,
|
||||
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<String>,
|
||||
values: Array<String>,
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Int>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user