Show warning when group changes would clear member labels.

This commit is contained in:
jeffrey-signal
2026-03-11 15:34:42 -04:00
parent 35cf24b577
commit 73f5a3398c
13 changed files with 458 additions and 38 deletions

View File

@@ -4,4 +4,5 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
sealed class PermissionsSettingsEvents {
class GroupChangeError(val reason: GroupChangeFailureReason) : PermissionsSettingsEvents()
object ShowMemberLabelsWillBeRemovedWarning : PermissionsSettingsEvents()
}

View File

@@ -3,6 +3,7 @@ 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
@@ -39,6 +40,7 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
viewModel.events.observe(viewLifecycleOwner) { event ->
when (event) {
is PermissionsSettingsEvents.GroupChangeError -> handleGroupChangeError(event)
is PermissionsSettingsEvents.ShowMemberLabelsWillBeRemovedWarning -> showMemberLabelsWillBeRemovedDialog()
}
}
}
@@ -94,13 +96,26 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
selected = getSelected(state.nonAdminCanSetMemberLabel),
confirmAction = true,
onSelected = { selectedIndex ->
viewModel.setNonAdminCanSetMemberLabel(selectedIndex == 1)
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) {

View File

@@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.components.settings.conversation.permissions
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
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.groups.GroupAccessControl
import org.thoughtcrime.securesms.groups.GroupChangeException
import org.thoughtcrime.securesms.groups.GroupId
@@ -13,7 +16,10 @@ import java.io.IOException
private val TAG = Log.tag(PermissionsSettingsRepository::class.java)
class PermissionsSettingsRepository(private val context: Context) {
class PermissionsSettingsRepository(
private val context: Context,
private val groupTable: GroupTable = SignalDatabase.groups
) {
fun applyMembershipRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) {
SignalExecutors.UNBOUNDED.execute {
@@ -57,6 +63,12 @@ class PermissionsSettingsRepository(private val context: Context) {
}
}
fun hasNonAdminMembersWithLabels(groupId: GroupId): Boolean {
val v2GroupId = groupId.v2OrNull() ?: return false
val group = groupTable.getGroup(v2GroupId).orNull() ?: return false
return group.requireV2GroupProperties().nonAdminMembersWithLabels().isNotEmpty()
}
fun applyMemberLabelRightsChange(groupId: GroupId, newRights: GroupAccessControl, errorCallback: GroupChangeErrorCallback) {
SignalExecutors.UNBOUNDED.execute {
try {

View File

@@ -11,11 +11,11 @@ import org.thoughtcrime.securesms.util.livedata.Store
class PermissionsSettingsViewModel(
private val groupId: GroupId,
private val repository: PermissionsSettingsRepository
private val repository: PermissionsSettingsRepository,
liveGroup: LiveGroup = LiveGroup(groupId)
) : ViewModel() {
private val store = Store(PermissionsSettingsState())
private val liveGroup = LiveGroup(groupId)
private val internalEvents = SingleLiveEvent<PermissionsSettingsEvents>()
val state: LiveData<PermissionsSettingsState> = store.stateLiveData
@@ -61,7 +61,17 @@ class PermissionsSettingsViewModel(
}
}
fun setNonAdminCanSetMemberLabel(nonAdminCanSetMemberLabel: Boolean) {
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()

View File

@@ -42,8 +42,10 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsement
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken
import org.signal.storageservice.storage.protos.groups.AccessControl
import org.signal.storageservice.storage.protos.groups.Member
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
@@ -1295,6 +1297,24 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
}
}
fun nonAdminMembersWithLabels(): List<DecryptedMember> {
return decryptedGroup.members
.filter { it.role != Member.Role.ADMINISTRATOR && it.hasLabel() }
}
/**
* Returns true if demoting [aci] from admin should cause their member label to be cleared.
*/
fun adminDemotionClearsLabel(aci: ACI): Boolean {
val accessRequired = decryptedGroup.accessControl?.memberLabel ?: AccessControl.AccessRequired.UNKNOWN
return when {
accessRequired != AccessControl.AccessRequired.ADMINISTRATOR -> false
else -> decryptedGroup.members.findMemberByAci(aci).orNull()?.hasLabel() == true
}
}
private fun DecryptedMember.hasLabel(): Boolean = labelString.isNotBlank() || labelEmoji.isNotBlank()
}
@Throws(BadGroupIdException::class)

View File

@@ -322,11 +322,27 @@ final class GroupManagerV2 {
}
@WorkerThread
@NonNull GroupManager.GroupActionResult updateMemberLabelRights(@NonNull GroupAccessControl newRights)
@NonNull
GroupManager.GroupActionResult updateMemberLabelRights(@NonNull GroupAccessControl newRights)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
AccessControl.AccessRequired accessRequired = rightsToAccessControl(newRights);
return commitChangeWithConflictResolution(selfAci, groupOperations.createChangeMemberLabelRights(accessRequired));
AccessControl.AccessRequired newAccess = rightsToAccessControl(newRights);
GroupChange.Actions.Builder change = groupOperations.createChangeMemberLabelRights(newAccess);
DecryptedGroup decryptedGroup = v2GroupProperties.getDecryptedGroup();
AccessControl.AccessRequired currentAccess = decryptedGroup.accessControl != null ? decryptedGroup.accessControl.memberLabel : AccessControl.AccessRequired.UNKNOWN;
if (newAccess == AccessControl.AccessRequired.ADMINISTRATOR && currentAccess != AccessControl.AccessRequired.ADMINISTRATOR) {
List<ACI> membersWithLabelsToClear = v2GroupProperties.nonAdminMembersWithLabels()
.stream()
.map(member -> ACI.parseOrNull(member.aciBytes))
.filter(Objects::nonNull)
.collect(Collectors.toList());
if (!membersWithLabelsToClear.isEmpty()) {
change.modifyMemberLabels(groupOperations.createRemoveMemberLabelsChange(membersWithLabelsToClear).modifyMemberLabels);
}
}
return commitChangeWithConflictResolution(selfAci, change);
}
@WorkerThread
@@ -338,7 +354,7 @@ final class GroupManagerV2 {
@WorkerThread
@NonNull GroupManager.GroupActionResult updateGroupTitleDescriptionAndAvatar(@Nullable String title, @Nullable String description, @Nullable byte[] avatarBytes, boolean avatarChanged)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
try {
GroupChange.Actions.Builder change = title != null ? groupOperations.createModifyGroupTitle(title)
@@ -409,8 +425,14 @@ final class GroupManagerV2 {
boolean admin)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Recipient recipient = Recipient.resolved(recipientId);
return commitChangeWithConflictResolution(selfAci, groupOperations.createChangeMemberRole(recipient.requireAci(), admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT));
Recipient recipient = Recipient.resolved(recipientId);
ACI recipientAci = recipient.requireAci();
GroupChange.Actions.Builder change = groupOperations.createChangeMemberRole(recipientAci, admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT);
if (!admin && v2GroupProperties.adminDemotionClearsLabel(recipientAci)) {
change.modifyMemberLabels(groupOperations.createRemoveMemberLabelsChange(Collections.singletonList(recipientAci)).modifyMemberLabels);
}
return commitChangeWithConflictResolution(selfAci, change);
}
@WorkerThread
@@ -1261,7 +1283,7 @@ final class GroupManagerV2 {
throw new GroupChangeFailedException("Unable to cancel group join request after conflicts");
}
}
}
private abstract static class LockOwner implements Closeable {
final Closeable lock;
@@ -1339,16 +1361,11 @@ final class GroupManagerV2 {
}
private static @NonNull AccessControl.AccessRequired rightsToAccessControl(@NonNull GroupAccessControl rights) {
switch (rights){
case ALL_MEMBERS:
return AccessControl.AccessRequired.MEMBER;
case ONLY_ADMINS:
return AccessControl.AccessRequired.ADMINISTRATOR;
case NO_ONE:
return AccessControl.AccessRequired.UNSATISFIABLE;
default:
throw new AssertionError();
}
return switch (rights) {
case ALL_MEMBERS -> AccessControl.AccessRequired.MEMBER;
case ONLY_ADMINS -> AccessControl.AccessRequired.ADMINISTRATOR;
case NO_ONE -> AccessControl.AccessRequired.UNSATISFIABLE;
};
}
static class RecipientAndThread {

View File

@@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import org.signal.core.models.ServiceId;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.GroupTable;
@@ -21,7 +23,6 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback;
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.core.util.concurrent.SimpleTask;
import java.io.IOException;
import java.util.ArrayList;
@@ -104,6 +105,24 @@ final class RecipientDialogRepository {
onComplete::accept);
}
void willAdminDemotionClearLabel(@NonNull Consumer<Boolean> onComplete) {
SimpleTask.BackgroundTask<Boolean> hasLabelToClear = () -> {
if (groupId == null || !groupId.isV2()) {
return false;
}
GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId.requireV2()).orElse(null);
ServiceId.ACI aci = Recipient.resolved(recipientId).getAci().orElse(null);
if (groupRecord != null && aci != null) {
return groupRecord.requireV2GroupProperties().adminDemotionClearsLabel(aci);
}
return false;
};
SimpleTask.run(SignalExecutors.UNBOUNDED, hasLabelToClear, onComplete::accept);
}
void getGroupMembership(@NonNull Consumer<List<RecipientId>> onComplete) {
SimpleTask.run(SignalExecutors.UNBOUNDED,
() -> {

View File

@@ -4,6 +4,7 @@ import android.app.Activity;
import android.content.Context;
import android.widget.Toast;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
@@ -263,22 +264,30 @@ final class RecipientDialogViewModel extends ViewModel {
.show();
}
@MainThread
void onRemoveGroupAdminClicked(@NonNull Activity activity) {
new MaterialAlertDialogBuilder(activity)
.setMessage(context.getString(R.string.RecipientBottomSheet_remove_s_as_group_admin, Objects.requireNonNull(recipient.getValue()).getDisplayName(context)))
.setPositiveButton(R.string.RecipientBottomSheet_remove_as_admin,
(dialog, which) -> {
adminActionBusy.setValue(true);
recipientDialogRepository.setMemberAdmin(false, result -> {
adminActionBusy.setValue(false);
if (!result) {
Toast.makeText(activity, R.string.ManageGroupActivity_failed_to_update_the_group, Toast.LENGTH_SHORT).show();
}
},
this::showErrorToast);
})
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {})
.show();
Recipient groupMember = Objects.requireNonNull(recipient.getValue());
recipientDialogRepository.willAdminDemotionClearLabel(willDemotionClearLabel -> {
int messageRes = willDemotionClearLabel ? R.string.RecipientBottomSheet_remove_s_as_group_admin_and_clear_member_label
: R.string.RecipientBottomSheet_remove_s_as_group_admin;
new MaterialAlertDialogBuilder(activity)
.setMessage(context.getString(messageRes, groupMember.getDisplayName(context)))
.setPositiveButton(R.string.RecipientBottomSheet_remove_as_admin,
(dialog, which) -> {
adminActionBusy.setValue(true);
recipientDialogRepository.setMemberAdmin(false, result -> {
adminActionBusy.setValue(false);
if (!result) {
Toast.makeText(activity, R.string.ManageGroupActivity_failed_to_update_the_group, Toast.LENGTH_SHORT).show();
}
},
this::showErrorToast);
})
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {})
.show();
});
}
void onRemoveFromGroupClicked(@NonNull Activity activity, boolean isLinkActive, @NonNull Runnable onSuccess) {