Add group terminate support.

This commit is contained in:
Cody Henthorne
2026-03-19 16:10:26 -04:00
parent 0896718e5c
commit a0c0acb8fc
130 changed files with 1312 additions and 146 deletions

View File

@@ -76,7 +76,7 @@ public final class BlockUnblockDialog {
Resources resources = context.getResources();
if (recipient.isGroup()) {
if (SignalDatabase.groups().isActive(recipient.requireGroupId())) {
if (SignalDatabase.groups().isMember(recipient.requireGroupId())) {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_and_leave_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates);
builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_leave, ((dialog, which) -> onBlock.run()));
@@ -121,7 +121,7 @@ public final class BlockUnblockDialog {
Resources resources = context.getResources();
if (recipient.isGroup()) {
if (SignalDatabase.groups().isActive(recipient.requireGroupId())) {
if (SignalDatabase.groups().isMember(recipient.requireGroupId())) {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you);
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));

View File

@@ -97,7 +97,7 @@ fun RecipientTable.getGroupsForBackup(selfAci: ServiceId.ACI): GroupArchiveExpor
"${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}",
"${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}",
"${GroupTable.TABLE_NAME}.${GroupTable.TITLE}",
"${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE}",
"${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER}",
"${GroupTable.TABLE_NAME}.${GroupTable.V2_DECRYPTED_GROUP}"
)
.from(

View File

@@ -50,7 +50,7 @@ class GroupArchiveExporter(private val selfAci: ServiceId.ACI, private val curso
val extras = RecipientTableCursorUtil.getExtras(cursor)
val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE))
val isActive: Boolean = cursor.requireBoolean(GroupTable.ACTIVE)
val isMember: Boolean = cursor.requireBoolean(GroupTable.IS_MEMBER)
val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!)
return ArchiveRecipient(
@@ -61,7 +61,7 @@ class GroupArchiveExporter(private val selfAci: ServiceId.ACI, private val curso
blocked = cursor.requireBoolean(RecipientTable.BLOCKED),
hideStory = extras?.hideStory() ?: false,
storySendMode = showAsStoryState.toRemote(),
snapshot = decryptedGroup.toRemote(isActive, selfAci),
snapshot = decryptedGroup.toRemote(isMember, selfAci),
avatarColor = cursor.requireString(RecipientTable.AVATAR_COLOR)?.let { AvatarColor.deserialize(it) }?.toRemote()
)
)
@@ -80,9 +80,9 @@ private fun GroupTable.ShowAsStoryState.toRemote(): Group.StorySendMode {
}
}
private fun DecryptedGroup.toRemote(isActive: Boolean, selfAci: ServiceId.ACI): Group.GroupSnapshot? {
private fun DecryptedGroup.toRemote(isMember: Boolean, selfAci: ServiceId.ACI): Group.GroupSnapshot? {
val selfAciBytes = selfAci.toByteString()
val memberFilter = { m: DecryptedMember -> isActive || m.aciBytes != selfAciBytes }
val memberFilter = { m: DecryptedMember -> isMember || m.aciBytes != selfAciBytes }
return Group.GroupSnapshot(
title = Group.GroupAttributeBlob(title = this.title),
@@ -96,7 +96,8 @@ private fun DecryptedGroup.toRemote(isActive: Boolean, selfAci: ServiceId.ACI):
inviteLinkPassword = this.inviteLinkPassword,
description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) },
announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED,
members_banned = this.bannedMembers.map { it.toRemote() }
members_banned = this.bannedMembers.map { it.toRemote() },
terminated = this.terminated
)
}

View File

@@ -162,6 +162,7 @@ private fun Group.GroupSnapshot.toLocal(operations: GroupsV2Operations.GroupOper
description = this.description?.descriptionText ?: "",
isAnnouncementGroup = if (this.announcements_only) EnabledState.ENABLED else EnabledState.DISABLED,
bannedMembers = this.members_banned.map { it.toLocal() },
terminated = this.terminated,
isPlaceholderGroup = isPlaceholder
)
}

View File

@@ -124,7 +124,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
if (resolvedRecipient.isPresent() && resolvedRecipient.get().isGroup()) {
Recipient recipient = resolvedRecipient.get();
if (SignalDatabase.groups().isActive(recipient.requireGroupId())) {
if (SignalDatabase.groups().isMember(recipient.requireGroupId())) {
builder.setTitle(getString(R.string.BlockUnblockDialog_block_and_leave_s, displayName));
builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates);
} else {

View File

@@ -71,7 +71,11 @@ class CallLogContextMenu(
)
}
private fun getVideoCallActionItem(peer: Recipient): ActionItem {
private fun getVideoCallActionItem(peer: Recipient): ActionItem? {
if (peer.isGroup && !peer.isActiveGroup) {
return null
}
// TODO [alex] -- Need group calling disposition to make this correct
return ActionItem(
iconRes = R.drawable.symbol_video_24,

View File

@@ -20,6 +20,7 @@ import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.Navigation
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
@@ -28,6 +29,7 @@ import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.DimensionUnit
import org.signal.core.util.Result
@@ -48,6 +50,7 @@ import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
@@ -67,6 +70,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.L
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.TerminatedBannerPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
@@ -74,6 +78,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
import org.thoughtcrime.securesms.groups.ui.EndGroupDialog
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
@@ -147,6 +152,12 @@ class ConversationSettingsFragment :
}
}
private val endGroupIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_x_circle_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
}
}
private val viewModel by viewModels<ConversationSettingsViewModel>(
factoryProducer = {
val groupId = args.groupId as? GroupId
@@ -268,6 +279,7 @@ class ConversationSettingsFragment :
InternalPreference.register(adapter)
GroupDescriptionPreference.register(adapter)
LegacyGroupPreference.register(adapter)
TerminatedBannerPreference.register(adapter)
CallPreference.register(adapter)
val recipientId = args.recipientId
@@ -330,10 +342,17 @@ class ConversationSettingsFragment :
return@configure
}
state.withGroupSettingsState {
if (it.isTerminated) {
customPref(TerminatedBannerPreference.Model())
}
}
customPref(
AvatarPreference.Model(
recipient = state.recipient,
storyViewState = state.storyViewState,
reduceTopMargin = state.isTerminatedGroup,
onAvatarClick = { avatar ->
val viewAvatarIntent = AvatarPreviewActivity.intentFromRecipientId(requireContext(), state.recipient.id)
val viewAvatarTransitionBundle = AvatarPreviewActivity.createTransitionBundle(requireActivity(), avatar)
@@ -392,7 +411,7 @@ class ConversationSettingsFragment :
)
)
if (groupState.groupId.isV2) {
if (groupState.groupId.isV2 && !groupState.isTerminated) {
customPref(
GroupDescriptionPreference.Model(
groupId = groupState.groupId,
@@ -793,10 +812,15 @@ class ConversationSettingsFragment :
if (groupState.canAddToGroup || memberCount > 0) {
dividerPref()
sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)))
val memberHeaderText = if (groupState.isTerminated) {
resources.getQuantityString(R.plurals.ConversationSettingsFragment__d_former_members, memberCount, memberCount)
} else {
resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)
}
sectionHeaderPref(DSLSettingsText.from(memberHeaderText))
}
if (groupState.canAddToGroup && !state.isDeprecatedOrUnregistered) {
if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
@@ -851,14 +875,14 @@ class ConversationSettingsFragment :
)
}
if (state.recipient.isPushV2Group) {
if (state.recipient.isPushV2Group && !groupState.isTerminated) {
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link),
summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off),
icon = DSLSettingsIcon.from(R.drawable.ic_link_16),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
onClick = {
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId.requireV2().toString()))
}
@@ -880,7 +904,7 @@ class ConversationSettingsFragment :
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites),
icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
onClick = {
startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2()))
}
@@ -890,7 +914,7 @@ class ConversationSettingsFragment :
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions),
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(groupState.groupId)
navController.safeNavigate(action)
@@ -913,7 +937,46 @@ class ConversationSettingsFragment :
}
}
if (state.canModifyBlockedState) {
state.withGroupSettingsState { groupState ->
if (groupState.isTerminated) {
dividerPref()
if (state.isArchived) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationListFragment_unarchive),
icon = DSLSettingsIcon.from(R.drawable.symbol_archive_up_24),
onClick = {
viewModel.toggleArchive()
}
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__archive_chat),
icon = DSLSettingsIcon.from(R.drawable.symbol_archive_24),
onClick = {
viewModel.toggleArchive()
onToolbarNavigationClicked()
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__delete_chat, alertTint),
icon = DSLSettingsIcon.from(CoreUiR.drawable.symbol_trash_24, R.color.signal_alert_primary),
onClick = {
val progressDialog = ProgressCardDialogFragment.create(getString(R.string.ConversationFragment_deleting_messages))
progressDialog.show(parentFragmentManager, null)
lifecycleScope.launch {
viewModel.deleteChat()
progressDialog.dismissAllowingStateLoss()
onToolbarNavigationClicked()
}
}
)
}
}
if (state.canModifyBlockedState && !state.isTerminatedGroup) {
state.withRecipientSettingsState {
dividerPref()
}
@@ -1005,6 +1068,50 @@ class ConversationSettingsFragment :
)
}
}
state.withGroupSettingsState { groupState ->
if (groupState.isTerminated) {
dividerPref()
val reportSpamTint = R.color.signal_alert_primary
clickPref(
title = DSLSettingsText.from(R.string.ConversationFragment_report_spam, ContextCompat.getColor(requireContext(), reportSpamTint)),
icon = DSLSettingsIcon.from(R.drawable.symbol_spam_24, reportSpamTint),
onClick = {
BlockUnblockDialog.showReportSpamFor(
requireContext(),
viewLifecycleOwner.lifecycle,
state.recipient,
{
viewModel
.onReportSpam()
.subscribeBy {
Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show()
onToolbarNavigationClicked()
}
.addTo(lifecycleDisposable)
},
null
)
}
)
}
}
state.withGroupSettingsState { groupState ->
if (groupState.canEndGroup && RemoteConfig.groupTerminateSend) {
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__end_group, if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint),
icon = DSLSettingsIcon.from(endGroupIcon),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
EndGroupDialog.show(requireActivity(), groupState.groupId.requireV2(), groupState.groupTitle)
}
)
}
}
}
}

View File

@@ -222,9 +222,18 @@ class ConversationSettingsRepository(
return liveGroup.getMembershipCountDescription(context.resources)
}
fun getExternalPossiblyMigratedGroupRecipientId(groupId: GroupId, consumer: (RecipientId) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(Recipient.externalPossiblyMigratedGroup(groupId).id)
}
@WorkerThread
fun isArchived(recipientId: RecipientId): Boolean {
return SignalDatabase.threads.isArchived(recipientId)
}
@WorkerThread
fun setArchived(threadId: Long, archived: Boolean) {
SignalDatabase.threads.setArchived(setOf(threadId), archived)
}
@WorkerThread
fun deleteChat(threadId: Long) {
SignalDatabase.threads.deleteConversation(threadId)
}
}

View File

@@ -20,6 +20,7 @@ data class ConversationSettingsState(
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
val disappearingMessagesLifespan: Int = 0,
val canModifyBlockedState: Boolean = false,
val isArchived: Boolean = false,
val sharedMedia: List<MediaTable.MediaRecord> = emptyList(),
val sharedMediaIds: List<Long> = listOf(),
val displayInternalRecipientDetails: Boolean = false,
@@ -29,6 +30,7 @@ data class ConversationSettingsState(
) {
val isLoaded: Boolean = recipient != Recipient.UNKNOWN && sharedMediaLoaded && specificSettingsState.isLoaded
val isTerminatedGroup: Boolean = (specificSettingsState as? SpecificSettingsState.GroupSettingsState)?.isTerminated == true
fun withRecipientSettingsState(consumer: (SpecificSettingsState.RecipientSettingsState) -> Unit) {
if (specificSettingsState is SpecificSettingsState.RecipientSettingsState) {
@@ -72,6 +74,8 @@ sealed class SpecificSettingsState {
val isSelfAdmin: Boolean = false,
val canAddToGroup: Boolean = false,
val canEditGroupAttributes: Boolean = false,
val isActive: Boolean = false,
val isTerminated: Boolean = false,
val canLeave: Boolean = false,
val canShowMoreGroupMembers: Boolean = false,
val groupMembersExpanded: Boolean = false,
@@ -88,6 +92,8 @@ sealed class SpecificSettingsState {
val canSetOwnMemberLabel: Boolean = false
) : SpecificSettingsState() {
val canEndGroup: Boolean get() = isActive && groupId.isV2 && isSelfAdmin
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
override fun requireGroupSettingsState(): GroupSettingsState = this

View File

@@ -144,6 +144,26 @@ sealed class ConversationSettingsViewModel(
disposable.clear()
}
fun toggleArchive() {
val state = store.state
if (state.threadId > 0) {
val newArchived = !state.isArchived
store.update { it.copy(isArchived = newArchived) }
viewModelScope.launch(SignalDispatchers.IO) {
repository.setArchived(state.threadId, newArchived)
}
}
}
suspend fun deleteChat() {
withContext(SignalDispatchers.IO) {
val threadId = store.state.threadId
if (threadId > 0) {
repository.deleteChat(threadId)
}
}
}
private class RecipientSettingsViewModel(
private val recipientId: RecipientId,
private val callMessageIds: LongArray,
@@ -299,21 +319,21 @@ sealed class ConversationSettingsViewModel(
store.update { it.copy(storyViewState = storyViewState) }
}
val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a }
store.update(recipientAndIsActive) { (recipient, isActive), state ->
store.update(liveGroup.groupRecipient) { recipient, state ->
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isMessageAvailable = callMessageIds.isNotEmpty(),
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive,
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup,
isAudioAvailable = false,
isAudioSecure = recipient.isPushV2Group,
isMuted = recipient.isMuted,
isMuteAvailable = true,
isSearchAvailable = callMessageIds.isEmpty(),
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive && !SignalStore.story.isFeatureDisabled
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup && !SignalStore.story.isFeatureDisabled
),
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
isArchived = repository.isArchived(recipient.id),
specificSettingsState = state.requireGroupSettingsState().copy(
legacyGroupState = getLegacyGroupState()
)
@@ -398,11 +418,20 @@ sealed class ConversationSettingsViewModel(
store.update(liveGroup.isActive) { isActive, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
isActive = isActive,
canLeave = isActive && groupId.isPush
)
)
}
store.update(liveGroup.isTerminated) { isTerminated, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
isTerminated = isTerminated
)
)
}
store.update(liveGroup.title) { title, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(

View File

@@ -7,6 +7,7 @@ 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
class PermissionsSettingsViewModel(
@@ -22,8 +23,8 @@ class PermissionsSettingsViewModel(
val events: LiveData<PermissionsSettingsEvents> = internalEvents
init {
store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state ->
state.copy(selfCanEditSettings = isSelfAdmin)
store.update(LiveDataUtil.combineLatest(liveGroup.isSelfAdmin, liveGroup.isActive) { admin, active -> admin && active }) { canEdit, state ->
state.copy(selfCanEditSettings = canEdit)
}
store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state ->

View File

@@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -25,6 +27,7 @@ object AvatarPreference {
class Model(
val recipient: Recipient,
val storyViewState: StoryViewState,
val reduceTopMargin: Boolean = false,
val onAvatarClick: (AvatarView) -> Unit,
val onBadgeClick: (Badge) -> Unit
) : PreferenceModel<Model>() {
@@ -35,7 +38,8 @@ object AvatarPreference {
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
recipient.hasSameContent(newItem.recipient) &&
storyViewState == newItem.storyViewState
storyViewState == newItem.storyViewState &&
reduceTopMargin == newItem.reduceTopMargin
}
}
@@ -49,6 +53,10 @@ object AvatarPreference {
}
override fun bind(model: Model) {
(itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.topMargin = if (model.reduceTopMargin) 0.dp else 40.dp
}
if (model.recipient.isSelf) {
badge.setBadge(null)
badge.setOnClickListener(null)

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object TerminatedBannerPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_terminated_banner))
}
class Model : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = true
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
override fun bind(model: Model) = Unit
}
}

View File

@@ -426,7 +426,7 @@ class ContactSearchPagedDataSource(
}
private fun canSendToGroup(groupRecord: GroupRecord?): Boolean {
if (groupRecord == null) return false
if (groupRecord == null || groupRecord.isTerminated) return false
return if (groupRecord.isAnnouncementGroup) {
groupRecord.isAdmin(Recipient.self())

View File

@@ -51,7 +51,7 @@ internal object ConversationOptionsMenu {
canShowAsBubble,
isActiveGroup,
isActiveV2Group,
isInActiveGroup,
isInactiveGroup,
hasActiveGroupCall,
distributionType,
threadId,
@@ -104,12 +104,12 @@ internal object ConversationOptionsMenu {
if (isPushAvailable) {
if (recipient.expiresInSeconds > 0) {
if (!isInActiveGroup) {
if (!isInactiveGroup) {
menuInflater.inflate(R.menu.conversation_expiring_on, menu)
}
callback.showExpiring(recipient)
} else {
if (!isInActiveGroup) {
if (!isInactiveGroup) {
menuInflater.inflate(R.menu.conversation_expiring_off, menu)
}
callback.clearExpiring()
@@ -150,6 +150,11 @@ internal object ConversationOptionsMenu {
hideMenuItem(menu, R.id.menu_mute_notifications)
}
if (recipient.isGroup && isInactiveGroup) {
hideMenuItem(menu, R.id.menu_mute_notifications)
hideMenuItem(menu, R.id.menu_unmute_notifications)
}
if (recipient.isBlocked) {
if (isPushAvailable) {
hideMenuItem(menu, R.id.menu_call_secure)

View File

@@ -171,11 +171,11 @@ public final class MenuState {
hasPollTerminate = true;
}
if (!messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) {
if (!messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift && !conversationRecipient.isInactiveGroup()) {
canPinMessage = true;
}
if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift) {
if (messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes() && canEditGroupInfo && !hasGift && !conversationRecipient.isInactiveGroup()) {
canUnpinMessage = true;
}
}

View File

@@ -573,10 +573,10 @@ class ConversationAdapterV2(
}
if (groupInfo.fullMemberCount > 0 || groupInfo.pendingMemberCount > 0) {
if (groupInfo.fullMemberCount == 1 && recipient.isActiveGroup) {
if (groupInfo.fullMemberCount == 1 && groupInfo.isMember) {
conversationBanner.hideUnverifiedNameSubtitle()
}
setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, recipient)
setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, groupInfo.isMember, recipient)
} else {
conversationBanner.hideSubtitle()
}
@@ -640,10 +640,10 @@ class ConversationAdapterV2(
conversationBanner.updateOutlineBoxSize()
}
private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List<Recipient>, recipient: Recipient) {
private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List<Recipient>, isMember: Boolean, recipient: Recipient) {
val names = members.map { member -> member.getDisplayName(context) }
val otherMembers = if (size > 3) context.resources.getQuantityString(R.plurals.MessageRequestProfileView_other_members, size - 3, size - 3) else null
val membersSubtitle = if (recipient.isActiveGroup) {
val membersSubtitle = if (isMember) {
when (names.size) {
0 -> context.getString(R.string.MessageRequestProfileView_group_members_zero)
1 -> context.getString(R.string.MessageRequestProfileView_group_members_one_and_you, names[0])

View File

@@ -10,6 +10,7 @@ import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.concurrent.SimpleTask
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
@@ -94,6 +95,18 @@ object ConversationDialogs {
.show()
}
fun displayTerminatedGroupSendFailedDialog(context: Context, messageRecord: MessageRecord) {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.conversation_activity__send_failed_group_ended)
.setNegativeButton(R.string.ConversationFragment_delete_for_me) { _, _ ->
SignalExecutors.BOUNDED.execute {
SignalDatabase.messages.deleteMessage(messageRecord.id)
}
}
.setPositiveButton(android.R.string.ok, null)
.show()
}
fun displayDeletionFailedDialog(context: Context, messageRecord: MessageRecord, canRetry: Boolean) {
if (canRetry) {
MaterialAlertDialogBuilder(context)

View File

@@ -99,6 +99,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
@@ -1171,6 +1172,26 @@ class ConversationFragment :
viewLifecycleOwner.lifecycle.addObserver(LastScrolledPositionUpdater(adapter, layoutManager, viewModel))
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
var wasTerminated: Boolean? = null
viewModel
.groupRecordFlow
.collect { record ->
val isTerminated = record.isTerminated
if (wasTerminated == false && isTerminated) {
val terminatedByRecipientId = record.terminatedByRecipientId
if (terminatedByRecipientId == null || terminatedByRecipientId != Recipient.self().id) {
val context = context ?: return@collect
val adminName = terminatedByRecipientId?.let { Recipient.resolved(it).getDisplayName(context) }
withContext(Dispatchers.Main) {
TerminatedGroupBottomSheetDialog.show(childFragmentManager, adminName)
}
}
}
wasTerminated = isTerminated
}
}
disposables += viewModel.recipient
.observeOn(AndroidSchedulers.mainThread())
.distinctUntilChanged { r1, r2 -> r1 === r2 || r1.hasSameContent(r2) }
@@ -1492,6 +1513,7 @@ class ConversationFragment :
inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized)
args.isIncognito -> disabledInputView.showAsIncognito()
!inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
inputReadyState.isTerminatedGroup -> disabledInputView.showAsTerminatedGroup()
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()
inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly()
@@ -3424,7 +3446,9 @@ class ConversationFragment :
override fun onMessageWithErrorClicked(messageRecord: MessageRecord) {
val recipientId = viewModel.recipientSnapshot?.id ?: return
if (messageRecord.isFailedAdminDelete) {
if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) {
ConversationDialogs.displayTerminatedGroupSendFailedDialog(requireContext(), messageRecord)
} else if (messageRecord.isFailedAdminDelete) {
val canRetry = MessageConstraintsUtil.isValidAdminDeleteSend(message = messageRecord, currentTime = System.currentTimeMillis(), isAdmin = conversationGroupViewModel.isAdmin(), isResend = true)
if (messageRecord.isIdentityMismatchFailure && canRetry) {
SafetyNumberBottomSheet

View File

@@ -183,7 +183,7 @@ class ConversationViewModel(
val messageRequestState: MessageRequestState
get() = hasMessageRequestStateSubject.value ?: MessageRequestState()
private val groupRecordFlow: Flow<GroupRecord>
val groupRecordFlow: Flow<GroupRecord>
private val refreshIdentityRecords: Subject<Unit> = PublishSubject.create()
private val identityRecordsStore: RxStore<IdentityRecordsState> = RxStore(IdentityRecordsState())

View File

@@ -44,6 +44,7 @@ class DisabledInputView @JvmOverloads constructor(
private var expiredOrUnauthorized: View? = null
private var messageRequestView: MessageRequestsBottomView? = null
private var noLongerAMember: View? = null
private var terminatedGroup: View? = null
private var requestingGroup: View? = null
private var announcementGroupOnly: TextView? = null
private var inviteToSignal: View? = null
@@ -126,6 +127,13 @@ class DisabledInputView @JvmOverloads constructor(
)
}
fun showAsTerminatedGroup() {
terminatedGroup = show(
existingView = terminatedGroup,
create = { inflater.inflate(R.layout.conversation_group_terminated, this, false) }
)
}
fun showAsRequestingMember() {
requestingGroup = show(
existingView = requestingGroup,
@@ -216,6 +224,7 @@ class DisabledInputView @JvmOverloads constructor(
messageRequestView?.hideBusy()
messageRequestView = null
noLongerAMember = null
terminatedGroup = null
requestingGroup = null
announcementGroupOnly = null
incognitoView = null

View File

@@ -30,7 +30,8 @@ class InputReadyState(
}
val isAnnouncementGroup: Boolean? = groupRecord?.isAnnouncementGroup
val isActiveGroup: Boolean? = if (selfMemberLevel == null) null else selfMemberLevel != GroupTable.MemberLevel.NOT_A_MEMBER
val isActiveGroup: Boolean? = groupRecord?.isActive
val isTerminatedGroup: Boolean = groupRecord?.isTerminated == true
val isAdmin: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.ADMINISTRATOR)
val isRequestingMember: Boolean? = selfMemberLevel?.equals(GroupTable.MemberLevel.REQUESTING_MEMBER)

View File

@@ -37,6 +37,11 @@ object PinSendUtil {
if (groupId != null) {
val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull()
if (groupRecord != null && !groupRecord.isActive) {
throw UndeliverableMessageException("Cannot pin messages in an inactive group!")
}
if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) {
throw UndeliverableMessageException("Non-admins cannot pin messages!")
}
@@ -83,6 +88,11 @@ object PinSendUtil {
val groupId = if (threadRecipient.isPushV2Group) threadRecipient.requireGroupId().requireV2() else null
if (groupId != null) {
val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull()
if (groupRecord != null && !groupRecord.isActive) {
throw UndeliverableMessageException("Cannot unpin messages in an inactive group!")
}
if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) {
throw UndeliverableMessageException("Non-admins cannot pin messages!")
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.R
/**
* Shown when a group is terminated while the user is actively viewing the conversation.
*/
class TerminatedGroupBottomSheetDialog : ComposeBottomSheetDialogFragment() {
companion object {
private const val ARG_ADMIN_NAME = "admin_name"
fun show(fragmentManager: FragmentManager, adminName: String?) {
TerminatedGroupBottomSheetDialog()
.apply { arguments = bundleOf(ARG_ADMIN_NAME to adminName) }
.show(fragmentManager, "terminated_group_sheet")
}
}
@Composable
override fun SheetContent() {
TerminatedGroupSheetContent(
adminName = requireArguments().getString(ARG_ADMIN_NAME),
onOkClick = { dismissAllowingStateLoss() }
)
}
}
@Composable
private fun TerminatedGroupSheetContent(adminName: String?, onOkClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
BottomSheets.Handle()
Text(
text = if (adminName != null) {
stringResource(R.string.TerminatedGroupBottomSheet__s_ended_the_group, adminName)
} else {
stringResource(R.string.TerminatedGroupBottomSheet__the_group_has_been_ended)
},
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 24.dp, bottom = 10.dp)
)
Text(
text = stringResource(R.string.TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 32.dp)
)
Buttons.LargeTonal(
onClick = onOkClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 24.dp)
) {
Text(text = stringResource(R.string.TerminatedGroupBottomSheet__okay))
}
}
}

View File

@@ -34,7 +34,6 @@ import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.InvalidInputException
@@ -103,7 +102,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
const val AVATAR_CONTENT_TYPE = "avatar_content_type"
const val AVATAR_DIGEST = "avatar_digest"
const val TIMESTAMP = "timestamp"
const val ACTIVE = "active"
const val IS_MEMBER = "active"
const val TERMINATED_BY = "terminated_by"
const val MMS = "mms"
const val EXPECTED_V2_ID = "expected_v2_id"
const val UNMIGRATED_V1_MEMBERS = "unmigrated_v1_members"
@@ -133,17 +133,18 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
$AVATAR_CONTENT_TYPE TEXT DEFAULT NULL,
$AVATAR_DIGEST BLOB DEFAULT NULL,
$TIMESTAMP INTEGER DEFAULT 0,
$ACTIVE INTEGER DEFAULT 1,
$MMS INTEGER DEFAULT 0,
$V2_MASTER_KEY BLOB DEFAULT NULL,
$V2_REVISION BLOB DEFAULT NULL,
$V2_DECRYPTED_GROUP BLOB DEFAULT NULL,
$EXPECTED_V2_ID TEXT UNIQUE DEFAULT NULL,
$UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL,
$DISTRIBUTION_ID TEXT UNIQUE DEFAULT NULL,
$SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code},
$IS_MEMBER INTEGER DEFAULT 1,
$MMS INTEGER DEFAULT 0,
$V2_MASTER_KEY BLOB DEFAULT NULL,
$V2_REVISION BLOB DEFAULT NULL,
$V2_DECRYPTED_GROUP BLOB DEFAULT NULL,
$EXPECTED_V2_ID TEXT UNIQUE DEFAULT NULL,
$UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL,
$DISTRIBUTION_ID TEXT UNIQUE DEFAULT NULL,
$SHOW_AS_STORY_STATE INTEGER DEFAULT ${ShowAsStoryState.IF_ACTIVE.code},
$LAST_FORCE_UPDATE_TIMESTAMP INTEGER DEFAULT 0,
$GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0
$GROUP_SEND_ENDORSEMENTS_EXPIRATION INTEGER DEFAULT 0,
$TERMINATED_BY INTEGER DEFAULT 0
)
"""
@@ -160,7 +161,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
AVATAR_CONTENT_TYPE,
AVATAR_DIGEST,
TIMESTAMP,
ACTIVE,
IS_MEMBER,
TERMINATED_BY,
MMS,
V2_MASTER_KEY,
V2_REVISION,
@@ -348,7 +350,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
FROM $TABLE_NAME
INNER JOIN ${MembershipTable.TABLE_NAME} ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE $TABLE_NAME.$ACTIVE = 1 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
WHERE $TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
ORDER BY $TITLE COLLATE NOCASE ASC
"""
@@ -404,9 +406,9 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
query = if (includeInactive) {
"($searchQuery) AND ($TABLE_NAME.$ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))"
"($searchQuery) AND ($TABLE_NAME.$IS_MEMBER = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))"
} else {
"($searchQuery) AND $TABLE_NAME.$ACTIVE = ?"
"($searchQuery) AND $TABLE_NAME.$IS_MEMBER = ? AND $TABLE_NAME.$TERMINATED_BY = 0"
}
queryArgs = buildArgs(*searchTokens.toTypedArray(), 1)
@@ -495,8 +497,11 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
if (!includeInactive) {
query += " AND $TABLE_NAME.$ACTIVE = ?"
query += " AND $TABLE_NAME.$IS_MEMBER = ?"
args = appendArg(args, "1")
query += " AND $TABLE_NAME.$TERMINATED_BY = ?"
args = appendArg(args, "0")
}
return readableDatabase
@@ -522,22 +527,23 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
return Reader(cursor)
}
fun getInactiveGroups(): Reader {
val query = SqlUtil.buildQuery("$TABLE_NAME.$ACTIVE = ?", false.toInt())
val select = "${joinedGroupSelect()} WHERE ${query.where}"
return Reader(readableDatabase.query(select, query.whereArgs))
}
fun getActiveGroupCount(): Int {
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ACTIVE = ?", 1)
.where("$IS_MEMBER = ? AND $TERMINATED_BY = ?", 1, 0)
.run()
.readToSingleInt(0)
}
fun setTerminatedBy(groupId: GroupId, recipientId: RecipientId) {
writableDatabase
.update(TABLE_NAME)
.values(TERMINATED_BY to recipientId.serialize())
.where("$GROUP_ID = ?", groupId)
.run()
}
@WorkerThread
fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List<RecipientId> {
return if (groupId.isV2) {
@@ -688,14 +694,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
values.put(TIMESTAMP, System.currentTimeMillis())
if (groupId.isV2) {
values.put(ACTIVE, if (groupState != null && gv2GroupActive(groupState)) 1 else 0)
values.put(IS_MEMBER, if (groupState != null && isGroupMember(groupState)) 1 else 0)
values.put(TERMINATED_BY, if (groupState?.terminated == true) -1 else 0)
values.put(DISTRIBUTION_ID, DistributionId.create().toString())
values.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements?.expirationMs ?: 0)
} else if (groupId.isV1) {
values.put(ACTIVE, 1)
values.put(IS_MEMBER, 1)
values.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString())
} else {
values.put(ACTIVE, 1)
values.put(IS_MEMBER, 1)
}
if (groupMasterKey != null) {
@@ -793,7 +800,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
contentValues.put(TITLE, title)
contentValues.put(V2_REVISION, decryptedGroup.revision)
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.encode())
contentValues.put(ACTIVE, if (gv2GroupActive(decryptedGroup)) 1 else 0)
contentValues.put(IS_MEMBER, if (isGroupMember(decryptedGroup)) 1 else 0)
contentValues.put(TERMINATED_BY, if (decryptedGroup.terminated) -1 else 0)
if (receivedGroupSendEndorsements != null) {
contentValues.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements.expirationMs)
@@ -938,10 +946,15 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
return record.isPresent && record.get().isActive
}
fun setActive(groupId: GroupId, active: Boolean) {
fun isMember(groupId: GroupId): Boolean {
val record = getGroup(groupId)
return record.isPresent && record.get().isMember
}
fun setMember(groupId: GroupId, isMember: Boolean) {
writableDatabase
.update(TABLE_NAME)
.values(ACTIVE to if (active) 1 else 0)
.values(IS_MEMBER to if (isMember) 1 else 0)
.where("$GROUP_ID = ?", groupId)
.run()
}
@@ -1079,6 +1092,12 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
writableDatabase
.update(TABLE_NAME)
.values(TERMINATED_BY to toId.toLong())
.where("$TERMINATED_BY = ?", fromId.toLong())
.run()
// Remap all recipients that would not result in conflicts
writableDatabase.execSQL(
"""
@@ -1139,7 +1158,8 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
avatarId = cursor.requireLong(AVATAR_ID),
avatarKey = cursor.requireBlob(AVATAR_KEY),
avatarContentType = cursor.requireString(AVATAR_CONTENT_TYPE),
isActive = cursor.requireBoolean(ACTIVE),
isMember = cursor.requireBoolean(IS_MEMBER),
terminatedBy = cursor.requireLong(TERMINATED_BY),
avatarDigest = cursor.requireBlob(AVATAR_DIGEST),
isMms = cursor.requireBoolean(MMS),
groupMasterKeyBytes = cursor.requireBlob(V2_MASTER_KEY),
@@ -1345,7 +1365,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
) AS active_timestamp
FROM $TABLE_NAME INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE
$TABLE_NAME.$ACTIVE = 1 AND
$TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND
(
$SHOW_AS_STORY_STATE = ${ShowAsStoryState.ALWAYS.code} OR
(
@@ -1394,7 +1414,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
}
}
private fun gv2GroupActive(decryptedGroup: DecryptedGroup): Boolean {
private fun isGroupMember(decryptedGroup: DecryptedGroup): Boolean {
val aci = SignalStore.account.requireAci()
return decryptedGroup.members.findMemberByAci(aci).isPresent ||

View File

@@ -2861,7 +2861,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
val silent = (MessageTypes.isGroupUpdate(type) && !retrieved.isGroupAdd) ||
val silent = (MessageTypes.isGroupUpdate(type) && !retrieved.isNotifiable) ||
retrieved.type == MessageType.IDENTITY_DEFAULT ||
retrieved.type == MessageType.IDENTITY_VERIFIED ||
retrieved.type == MessageType.IDENTITY_UPDATE

View File

@@ -3745,7 +3745,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
val threadDatabase = threads
val recipientsWithinInteractionThreshold: MutableSet<RecipientId> = LinkedHashSet()
threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1, false)).use { reader ->
threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1)).use { reader ->
var record: ThreadRecord? = reader.getNext()
while (record != null && record.date > lastInteractionThreshold) {
@@ -4830,7 +4830,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SELECT 1
FROM ${GroupTable.MembershipTable.TABLE_NAME}
INNER JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} = ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.GROUP_ID}
WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
)
"""
val E164_SEARCH = "(($PHONE_NUMBER_SHARING != ${PhoneNumberSharingState.DISABLED.id} OR $SYSTEM_CONTACT_URI NOT NULL) AND $E164 GLOB ?)"

View File

@@ -873,7 +873,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
var where = ""
if (!includeInactiveGroups) {
where += "$MEANINGFUL_MESSAGES != 0 AND (${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} IS NULL OR ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1)"
where += "$MEANINGFUL_MESSAGES != 0 AND (${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} IS NULL OR (${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0))"
} else {
where += "$MEANINGFUL_MESSAGES != 0"
}
@@ -922,8 +922,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
return readableDatabase.rawQuery(query, null)
}
fun getRecentPushConversationList(limit: Int, includeInactiveGroups: Boolean): Cursor {
val activeGroupQuery = if (!includeInactiveGroups) " AND " + GroupTable.TABLE_NAME + "." + GroupTable.ACTIVE + " = 1" else ""
fun getRecentPushConversationList(limit: Int): Cursor {
val where = """
$MEANINGFUL_MESSAGES != 0
AND (
@@ -931,7 +930,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
OR (
${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} NOT NULL
AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
$activeGroupQuery
AND ${GroupTable.TABLE_NAME}.${GroupTable.IS_MEMBER} = 1
AND ${GroupTable.TABLE_NAME}.${GroupTable.TERMINATED_BY} = 0
)
)
"""

View File

@@ -161,6 +161,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNo
import org.thoughtcrime.securesms.database.helpers.migration.V305_AddStoryArchivedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V306_AddRemoteDeletedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V308_AddBackRemoteDeletedColumn
import org.thoughtcrime.securesms.database.helpers.migration.V309_GroupTerminatedColumnMigration
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -329,10 +330,11 @@ object SignalDatabaseMigrations {
305 to V305_AddStoryArchivedColumn,
306 to V306_AddRemoteDeletedColumn,
// 307 to V307_RemoveRemoteDeletedColumn - Removed due to unsolvable OOM crashes. [TODO]: Attempt to fix in the future
308 to V308_AddBackRemoteDeletedColumn
308 to V308_AddBackRemoteDeletedColumn,
309 to V309_GroupTerminatedColumnMigration
)
const val DATABASE_VERSION = 308
const val DATABASE_VERSION = 309
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds 'terminated_by' that stores the recipient id of the
* admin who terminated the group, -1 if unknown, 0 if not terminated.
*/
@Suppress("ClassName")
object V309_GroupTerminatedColumnMigration : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE groups ADD COLUMN terminated_by INTEGER DEFAULT 0")
}
}

View File

@@ -24,7 +24,8 @@ class GroupRecord(
val avatarId: Long,
val avatarKey: ByteArray?,
val avatarContentType: String?,
val isActive: Boolean,
val isMember: Boolean,
private val terminatedBy: Long = 0,
val avatarDigest: ByteArray?,
val isMms: Boolean,
groupMasterKeyBytes: ByteArray?,
@@ -61,6 +62,15 @@ class GroupRecord(
}
}
val isTerminated: Boolean
get() = terminatedBy != 0L
val terminatedByRecipientId: RecipientId?
get() = if (terminatedBy > 0) RecipientId.from(terminatedBy) else null
val isActive: Boolean
get() = isMember && !isTerminated
val description: String
get() = v2GroupProperties?.decryptedGroup?.description ?: ""

View File

@@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupMembershipAccessLevelChan
import org.thoughtcrime.securesms.backup.v2.proto.GroupNameUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupSelfInvitationRevokedUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupSequenceOfRequestsAndCancelsUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupTerminateChangeUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupUnknownInviteeUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel
import org.thoughtcrime.securesms.backup.v2.proto.SelfInvitedOtherUserToGroupUpdate
@@ -155,6 +156,7 @@ object GroupsV2UpdateMessageConverter {
translateAnnouncementGroupChange(change, editorUnknown, updates)
translatePromotePendingPniAci(selfIds, change, editorUnknown, updates)
translateMemberRemovals(selfIds, change, editorUnknown, updates)
translateTerminateGroup(change, editorUnknown, updates)
if (updates.isEmpty()) {
translateUnknownChange(change, editorUnknown, updates)
}
@@ -684,6 +686,20 @@ object GroupsV2UpdateMessageConverter {
}
}
@JvmStatic
fun translateTerminateGroup(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList<GroupChangeChatUpdate.Update>) {
if (change.terminateGroup) {
val editorAci = if (editorUnknown) null else change.editorServiceIdBytes
updates.add(
GroupChangeChatUpdate.Update(
groupTerminateChangeUpdate = GroupTerminateChangeUpdate(
updaterAci = editorAci
)
)
)
}
}
@JvmStatic
fun translateUnknownChange(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList<GroupChangeChatUpdate.Update>) {
updates.add(

View File

@@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.GroupMemberRemovedUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupMembershipAccessLevelChangeUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupNameUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupSelfInvitationRevokedUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupTerminateChangeUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupUnknownInviteeUpdate;
import org.thoughtcrime.securesms.backup.v2.proto.GroupV2AccessLevel;
import org.thoughtcrime.securesms.backup.v2.proto.GroupV2MigrationDroppedMembersUpdate;
@@ -220,6 +221,8 @@ final class GroupsV2UpdateMessageProducer {
describeGroupExpirationTimerUpdate(update.groupExpirationTimerUpdate, updates);
} else if (update.groupSelfInvitationRevokedUpdate != null) {
describeGroupSelfInvitationRevokedUpdate(update.groupSelfInvitationRevokedUpdate, updates);
} else if (update.groupTerminateChangeUpdate != null) {
describeGroupTerminateUpdate(update.groupTerminateChangeUpdate, updates);
}
}
@@ -231,6 +234,18 @@ final class GroupsV2UpdateMessageProducer {
}
}
private void describeGroupTerminateUpdate(@NonNull GroupTerminateChangeUpdate update, @NonNull List<UpdateDescription> updates) {
if (update.updaterAci == null) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_was_terminated), Glyph.X_CIRCLE));
} else {
if (selfIds.matches(update.updaterAci)) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_terminated_the_group), Glyph.X_CIRCLE));
} else {
updates.add(updateDescription(R.string.MessageRecord_s_terminated_the_group, update.updaterAci, Glyph.X_CIRCLE));
}
}
}
private void describeGroupExpirationTimerUpdate(@NonNull GroupExpirationTimerUpdate update, @NonNull List<UpdateDescription> updates) {
final int duration = Math.toIntExact(update.expiresInMs / 1000);
String time = ExpirationUtil.getExpirationDisplayValue(context, duration);

View File

@@ -75,6 +75,23 @@ public final class GroupManager {
}
}
@WorkerThread
public static void terminateGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
throws GroupChangeBusyException, GroupChangeFailedException, IOException
{
try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) {
edit.terminateGroup();
SignalDatabase.groups().setTerminatedBy(groupId, Recipient.self().getId());
Log.i(TAG, "Terminated group " + groupId);
} catch (GroupInsufficientRightsException e) {
Log.w(TAG, "Insufficient rights to terminate " + groupId, e);
throw new GroupChangeFailedException(e);
} catch (GroupNotAMemberException e) {
Log.w(TAG, "Not a member of " + groupId, e);
throw new GroupChangeFailedException(e);
}
}
@WorkerThread
public static void leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId, boolean sendToMembers)
throws GroupChangeBusyException, GroupChangeFailedException, IOException
@@ -216,7 +233,7 @@ public final class GroupManager {
{
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.acceptInvite();
SignalDatabase.groups().setActive(groupId, true);
SignalDatabase.groups().setMember(groupId, true);
}
}

View File

@@ -435,6 +435,13 @@ final class GroupManagerV2 {
return commitChangeWithConflictResolution(selfAci, change);
}
@WorkerThread
@NonNull GroupManager.GroupActionResult terminateGroup()
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
return commitChangeWithConflictResolution(selfAci, groupOperations.createTerminateGroup());
}
@WorkerThread
void leaveGroup(boolean sendToMembers)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException

View File

@@ -137,6 +137,10 @@ public final class LiveGroup {
return recipient;
}
public LiveData<GroupRecord> getGroupRecord() {
return groupRecord;
}
public LiveData<Boolean> isSelfAdmin() {
return Transformations.map(groupRecord, g -> g.isAdmin(Recipient.self()));
}
@@ -149,6 +153,14 @@ public final class LiveGroup {
return Transformations.map(groupRecord, GroupRecord::isActive);
}
public LiveData<Boolean> isTerminated() {
return Transformations.map(groupRecord, GroupRecord::isTerminated);
}
public LiveData<Boolean> isMember() {
return Transformations.map(groupRecord, GroupRecord::isMember);
}
public LiveData<Boolean> getRecipientIsAdmin(@NonNull RecipientId recipientId) {
return LiveDataUtil.mapAsync(groupRecord, g -> g.isAdmin(Recipient.resolved(recipientId)));
}
@@ -201,11 +213,13 @@ public final class LiveGroup {
}
public LiveData<Boolean> selfCanEditGroupAttributes() {
return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), LiveGroup::applyAccessControl);
return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), isActive(),
(level, access, active) -> active && applyAccessControl(level, access));
}
public LiveData<Boolean> selfCanAddMembers() {
return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), LiveGroup::applyAccessControl);
return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), isActive(),
(level, access, active) -> active && applyAccessControl(level, access));
}
/**

View File

@@ -98,6 +98,8 @@ class MemberLabelRepository private constructor(
suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) {
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false
if (groupRecord.isTerminated) return@withContext false
val memberLevel = groupRecord.memberLevel(recipient)
if (groupRecord.memberLabelAccessControl == GroupAccessControl.ONLY_ADMINS) {
memberLevel == GroupTable.MemberLevel.ADMINISTRATOR

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.ui
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.groups.GroupChangeBusyException
import org.thoughtcrime.securesms.groups.GroupChangeException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import java.io.IOException
/**
* Handles the end group flow for admins. Shows a two-step confirmation
* dialog before terminating the group.
*/
object EndGroupDialog {
private val TAG = Log.tag(EndGroupDialog::class.java)
@JvmStatic
fun show(activity: FragmentActivity, groupId: GroupId.V2, groupName: String) {
MaterialAlertDialogBuilder(activity)
.setTitle(activity.getString(R.string.EndGroupDialog__end_s, groupName))
.setMessage(R.string.EndGroupDialog__members_will_no_longer_be_able_to_send)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ ->
showFinalConfirmation(activity, groupId)
}
.show()
}
private fun showFinalConfirmation(activity: FragmentActivity, groupId: GroupId.V2) {
MaterialAlertDialogBuilder(activity)
.setMessage(R.string.EndGroupDialog__this_will_end_the_group_permanently)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.EndGroupDialog__end_group) { _, _ ->
performEndGroup(activity, groupId)
}
.show()
}
private fun performEndGroup(activity: FragmentActivity, groupId: GroupId.V2) {
val progressDialog = SignalProgressDialog.show(
context = activity,
message = activity.getString(R.string.EndGroupDialog__ending_group),
indeterminate = true
)
activity.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
GroupManager.terminateGroup(activity, groupId)
GroupChangeResult.SUCCESS
} catch (e: GroupChangeException) {
Log.w(TAG, "Failed to end group", e)
GroupChangeResult.failure(GroupChangeFailureReason.fromException(e))
} catch (e: GroupChangeBusyException) {
Log.w(TAG, "Failed to end group", e)
GroupChangeResult.failure(GroupChangeFailureReason.fromException(e))
} catch (e: IOException) {
Log.w(TAG, "Failed to end group", e)
GroupChangeResult.failure(GroupChangeFailureReason.fromException(e))
}
}
progressDialog.dismiss()
if (!result.isSuccess) {
showRetryDialog(activity, groupId)
}
}
}
private fun showRetryDialog(activity: FragmentActivity, groupId: GroupId.V2) {
MaterialAlertDialogBuilder(activity)
.setMessage(R.string.EndGroupDialog__ending_the_group_failed)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.EndGroupDialog__try_again) { _, _ ->
performEndGroup(activity, groupId)
}
.show()
}
}

View File

@@ -116,7 +116,12 @@ class GroupsV2StateProcessor private constructor(
return GroupUpdateResult.CONSISTENT_OR_AHEAD
}
return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isActive)) {
if (currentLocalState.terminated) {
Log.i(TAG, "$logPrefix Group is terminated, not updating")
return GroupUpdateResult.CONSISTENT_OR_AHEAD
}
return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true, forceUpdate = !groupRecord.isMember)) {
InternalUpdateResult.NoUpdateNeeded -> GroupUpdateResult.CONSISTENT_OR_AHEAD
is InternalUpdateResult.Updated -> GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_UPDATED, result.updatedLocalState)
is InternalUpdateResult.NotAMember -> throw result.exception
@@ -232,6 +237,11 @@ class GroupsV2StateProcessor private constructor(
return false
}
if (currentLocalState.terminated) {
Log.w(TAG, "$logPrefix Ignoring P2P group change because group is terminated")
return false
}
if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) {
Log.w(TAG, "$logPrefix Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.")
return false
@@ -320,8 +330,8 @@ class GroupsV2StateProcessor private constructor(
val applyGroupStateDiffResult: AdvanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(remoteGroupStateDiff, targetRevision)
val updatedGroupState: DecryptedGroup? = applyGroupStateDiffResult.updatedGroupState
if (groupRecord.map { it.isActive }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) {
Log.w(TAG, "$logPrefix Local state is not active, but server is returning state for us, apply regardless of revision")
if (groupRecord.map { it.isMember }.orNull() == false && updatedGroupState != null && updatedGroupState == remoteGroupStateDiff.previousGroupState) {
Log.w(TAG, "$logPrefix Local state is not a member, but server is returning state for us, apply regardless of revision")
} else if (updatedGroupState == null || updatedGroupState == remoteGroupStateDiff.previousGroupState) {
Log.i(TAG, "$logPrefix Local state is at or later than server revision: ${currentLocalState?.revision ?: "null"}")
@@ -436,7 +446,7 @@ class GroupsV2StateProcessor private constructor(
}
private fun notInGroupAndNotBeingAdded(groupRecord: Optional<GroupRecord>, signedGroupChange: DecryptedGroupChange): Boolean {
val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isActive
val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isMember
val addedAsMember = signedGroupChange
.newMembers
@@ -517,6 +527,20 @@ class GroupsV2StateProcessor private constructor(
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements)
if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) {
val terminatingChange = groupStateDiff.serverHistory
.mapNotNull { it.change }
.firstOrNull { it.terminateGroup }
if (terminatingChange != null) {
val editorServiceId = ServiceId.parseOrNull(terminatingChange.editorServiceIdBytes)
if (editorServiceId != null) {
val terminatorRecipientId = RecipientId.from(editorServiceId)
SignalDatabase.groups.setTerminatedBy(groupId, terminatorRecipientId)
}
}
}
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
@@ -707,7 +731,7 @@ class GroupsV2StateProcessor private constructor(
Log.w(TAG, "Failed to insert leave message for $groupId", e)
}
SignalDatabase.groups.setActive(groupId, false)
SignalDatabase.groups.setMember(groupId, false)
SignalDatabase.groups.remove(groupId, Recipient.self().id)
}
@@ -772,20 +796,24 @@ class GroupsV2StateProcessor private constructor(
}
} else {
try {
val isGroupAdd = updateDescription
.groupChangeUpdate!!
.updates
val updates = updateDescription.groupChangeUpdate!!.updates
val isGroupAdd = updates
.asSequence()
.mapNotNull { it.groupMemberAddedUpdate }
.any { serviceIds.matches(it.newMemberAci) }
val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isGroupAdd, serverGuid)
val isGroupTerminate = updates.any { it.groupTerminateChangeUpdate != null }
val isNotifiable = isGroupAdd || isGroupTerminate
val groupMessage = IncomingMessage.groupUpdate(RecipientId.from(editor.get()), timestamp, groupId, updateDescription, isNotifiable, serverGuid)
val insertResult = SignalDatabase.messages.insertMessageInbox(groupMessage)
if (insertResult.isPresent) {
SignalDatabase.threads.update(insertResult.get().threadId, unarchive = false, allowDeletion = false)
if (isGroupAdd) {
if (isNotifiable) {
AppDependencies.messageNotifier.updateNotification(AppDependencies.application)
}
} else {

View File

@@ -74,6 +74,11 @@ final class ForceUpdateGroupV2WorkerJob extends BaseJob {
return;
}
if (group.isPresent() && group.get().isTerminated()) {
Log.i(TAG, "Group is terminated, skipping force update.");
return;
}
GroupManager.forceSanityUpdateFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), System.currentTimeMillis());
SignalDatabase.groups().setLastForceUpdateTimestamp(group.get().getId(), System.currentTimeMillis());

View File

@@ -89,6 +89,11 @@ final class RequestGroupV2InfoWorkerJob extends BaseJob {
return;
}
if (group.isPresent() && group.get().isTerminated()) {
Log.i(TAG, "Group is terminated, skipping fetch.");
return;
}
GroupManager.updateGroupFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), toRevision, System.currentTimeMillis());
}

View File

@@ -88,7 +88,7 @@ class CameraContactsRepository {
List<Recipient> recipients = new ArrayList<>(RECENT_MAX);
try (ThreadTable.Reader threadReader = threadTable.readerFor(threadTable.getRecentPushConversationList(RECENT_MAX, false))) {
try (ThreadTable.Reader threadReader = threadTable.readerFor(threadTable.getRecentPushConversationList(RECENT_MAX))) {
ThreadRecord threadRecord;
while ((threadRecord = threadReader.getNext()) != null) {
recipients.add(threadRecord.getRecipient().resolve());

View File

@@ -10,7 +10,9 @@ class GroupInfo(
val pendingMemberCount: Int = 0,
val description: String = "",
val hasExistingContacts: Boolean = false,
val membersPreview: List<Recipient> = emptyList()
val membersPreview: List<Recipient> = emptyList(),
val isMember: Boolean = false,
val isTerminated: Boolean = false
) {
companion object {
@JvmField

View File

@@ -74,11 +74,11 @@ public final class MessageRequestRepository {
List<Recipient> membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList());
DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup();
groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview);
groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts, membersPreview, groupRecord.get().isMember(), groupRecord.get().isTerminated());
} else {
List<Recipient> membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList());
groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview);
groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false, membersPreview, groupRecord.get().isActive(), false);
}
}

View File

@@ -255,6 +255,13 @@ open class MessageContentProcessor(private val context: Context) {
return Gv2PreProcessResult.IGNORE
}
if (groupRecord.isPresent && groupRecord.get().isTerminated) {
if (content.dataMessage != null && !content.dataMessage!!.hasSignedGroupChange) {
Log.w(TAG, "Ignoring message from ${senderRecipient.id} because the group is terminated.")
return Gv2PreProcessResult.IGNORE
}
}
if (groupRecord.isPresent && groupRecord.get().isAnnouncementGroup && !groupRecord.get().admins.contains(senderRecipient)) {
if (content.dataMessage != null) {
if (content.dataMessage!!.hasDisallowedAnnouncementOnlyContent) {

View File

@@ -40,7 +40,7 @@ class IncomingMessage(
mentions: List<Mention> = emptyList(),
val giftBadge: GiftBadge? = null,
val messageExtras: MessageExtras? = null,
val isGroupAdd: Boolean = false,
val isNotifiable: Boolean = false,
val poll: Poll? = null
) {
@@ -99,7 +99,7 @@ class IncomingMessage(
}
@JvmStatic
fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, update: GV2UpdateDescription, isGroupAdd: Boolean, serverGuid: String?): IncomingMessage {
fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, update: GV2UpdateDescription, isNotifiable: Boolean, serverGuid: String?): IncomingMessage {
val messageExtras = MessageExtras(gv2UpdateDescription = update)
val groupContext = MessageGroupContext(update.gv2ChangeDescription!!)
@@ -113,7 +113,7 @@ class IncomingMessage(
groupContext = groupContext,
type = MessageType.GROUP_UPDATE,
messageExtras = messageExtras,
isGroupAdd = isGroupAdd
isNotifiable = isNotifiable
)
}
}

View File

@@ -323,6 +323,7 @@ public class CreateProfileFragment extends LoggingFragment {
}
} else {
Toast.makeText(requireContext(), R.string.CreateProfileActivity_problem_setting_profile, Toast.LENGTH_LONG).show();
binding.finishButton.cancelSpinning();
}
});

View File

@@ -113,6 +113,7 @@ class EditGroupProfileRepository implements EditProfileRepository {
return UploadResult.SUCCESS;
} catch (GroupChangeException | IOException e) {
Log.d(TAG, "Error updating group details", e);
return UploadResult.ERROR_IO;
}

View File

@@ -248,7 +248,7 @@ class Recipient(
participantIdsValue.isEmpty() || participantIdsValue.size == 1 && participantIdsValue.contains(self().id)
}
/** Whether the group is inactive. Groups become inactive when you leave them. */
/** Whether the group is inactive. Groups become inactive when you leave them or when the group is terminated. */
val isInactiveGroup: Boolean
get() = isGroup && !isActiveGroup

View File

@@ -11,7 +11,6 @@ import androidx.annotation.WorkerThread;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
@@ -23,6 +22,7 @@ import org.thoughtcrime.securesms.BlockUnblockDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2;
import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
@@ -53,8 +53,6 @@ import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import java.util.Objects;
import java.util.Optional;
import kotlin.Pair;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
@@ -92,18 +90,19 @@ final class RecipientDialogViewModel extends ViewModel {
if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2() && !recipientIsSelf) {
LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId());
LiveData<Pair<Boolean, Boolean>> localStatus = LiveDataUtil.combineLatest(source.isSelfAdmin(), Transformations.map(source.getGroupLink(), s -> s == null || s.isEnabled()), Pair::new);
LiveData<GroupTable.MemberLevel> recipientMemberLevel = Transformations.switchMap(recipient, source::getMemberLevel);
adminActionStatus = LiveDataUtil.combineLatest(source.getGroupRecord(), recipient, (group, r) -> {
boolean active = group.isActive();
boolean localAdmin = group.isAdmin(Recipient.self());
GroupTable.MemberLevel memberLevel = group.memberLevel(r);
boolean inGroup = memberLevel.isInGroup();
boolean recipientAdmin = memberLevel == GroupTable.MemberLevel.ADMINISTRATOR;
AccessControl.AccessRequired linkAccess = group.requireV2GroupProperties().getDecryptedGroup().accessControl != null ? group.requireV2GroupProperties().getDecryptedGroup().accessControl.addFromInviteLink
: AccessControl.AccessRequired.UNKNOWN;
boolean isLinkActive = linkAccess == AccessControl.AccessRequired.ANY || linkAccess == AccessControl.AccessRequired.ADMINISTRATOR;
adminActionStatus = LiveDataUtil.combineLatest(localStatus, recipientMemberLevel, (statuses, memberLevel) -> {
boolean localAdmin = statuses.getFirst();
boolean isLinkActive = statuses.getSecond();
boolean inGroup = memberLevel.isInGroup();
boolean recipientAdmin = memberLevel == GroupTable.MemberLevel.ADMINISTRATOR;
return new AdminActionStatus(inGroup && localAdmin,
inGroup && localAdmin && !recipientAdmin,
inGroup && localAdmin && recipientAdmin,
return new AdminActionStatus(active && inGroup && localAdmin,
active && inGroup && localAdmin && !recipientAdmin,
active && inGroup && localAdmin && recipientAdmin,
isLinkActive);
});
} else {

View File

@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors;
import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
final class ShareableGroupLinkViewModel extends ViewModel {
@@ -27,7 +28,7 @@ final class ShareableGroupLinkViewModel extends ViewModel {
this.repository = repository;
this.groupLink = liveGroup.getGroupLink();
this.canEdit = liveGroup.isSelfAdmin();
this.canEdit = LiveDataUtil.combineLatest(liveGroup.isSelfAdmin(), liveGroup.isActive(), (admin, active) -> admin && active);
this.toasts = new SingleLiveEvent<>();
this.busy = new SingleLiveEvent<>();
}

View File

@@ -286,8 +286,8 @@ public class CommunicationActions {
SimpleTask.run(SignalExecutors.BOUNDED, () -> {
GroupRecord group = SignalDatabase.groups().getGroup(groupId).orElse(null);
return group != null && group.isActive() ? Recipient.resolved(group.getRecipientId())
: null;
return group != null && (group.isMember() || group.isTerminated()) ? Recipient.resolved(group.getRecipientId())
: null;
},
recipient -> {
if (recipient != null) {

View File

@@ -1303,5 +1303,17 @@ object RemoteConfig {
defaultValue = 0,
hotSwappable = true
)
/**
* Whether or not to allow admins to terminate groups.
*/
@JvmStatic
@get:JvmName("groupTerminateSend")
val groupTerminateSend: Boolean by remoteBoolean(
key = "android.groupTerminateSend",
defaultValue = false,
hotSwappable = true
)
// endregion
}