mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-18 22:30:33 +01:00
Add group terminate support.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -313,7 +313,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
timestamp = wallClock,
|
||||
groupId = groupId,
|
||||
update = updateDescription,
|
||||
isGroupAdd = false,
|
||||
isNotifiable = false,
|
||||
serverGuid = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+6
-5
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+115
-8
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+33
-4
@@ -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(
|
||||
|
||||
+3
-2
@@ -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 ->
|
||||
|
||||
+9
-1
@@ -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)
|
||||
|
||||
+29
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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())
|
||||
|
||||
+8
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+25
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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!")
|
||||
}
|
||||
|
||||
+88
@@ -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
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+15
@@ -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 ?: ""
|
||||
|
||||
|
||||
+16
@@ -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(
|
||||
|
||||
+15
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
}
|
||||
+38
-10
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+13
-14
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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<>();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user