mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 09:20:19 +01:00
Add group terminate support.
This commit is contained in:
@@ -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()));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?)"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ?: ""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<>();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user