Convert group permissions page to compose.

This commit is contained in:
Alex Hart
2026-09-11 14:29:39 -03:00
committed by GitHub
parent c5d8ee5c80
commit 2863d7e1a2
14 changed files with 1353 additions and 375 deletions
@@ -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
}
@@ -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
}
)
}
}
@@ -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
)
}
@@ -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 = {}
)
}
@@ -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
}
}
@@ -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"
}
@@ -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)))
}
}
}
@@ -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()
}
}
@@ -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() }
}
}
+11
View File
@@ -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()
}
}