diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 323056eecd..93861e293b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -196,12 +196,14 @@ class ConfigToDatabaseSync @Inject constructor( private data class UpdateGroupInfo( val id: AccountId, val name: String?, + val destroyed: Boolean, val deleteBefore: Long?, val deleteAttachmentsBefore: Long? ) { constructor(groupInfoConfig: ReadableGroupInfoConfig) : this( id = groupInfoConfig.id(), name = groupInfoConfig.getName(), + destroyed = groupInfoConfig.isDestroyed(), deleteBefore = groupInfoConfig.getDeleteBefore(), deleteAttachmentsBefore = groupInfoConfig.getDeleteAttachmentsBefore() ) @@ -212,11 +214,16 @@ class ConfigToDatabaseSync @Inject constructor( val recipient = storage.getRecipientForThread(threadId) ?: return recipientDatabase.setProfileName(recipient, groupInfoConfig.name) profileManager.setName(context, recipient, groupInfoConfig.name ?: "") - groupInfoConfig.deleteBefore?.let { removeBefore -> - storage.trimThreadBefore(threadId, removeBefore) - } - groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore -> - mmsDatabase.deleteMessagesInThreadBeforeDate(threadId, removeAttachmentsBefore, onlyMedia = true) + + if (groupInfoConfig.destroyed) { + storage.clearMessages(threadId) + } else { + groupInfoConfig.deleteBefore?.let { removeBefore -> + storage.trimThreadBefore(threadId, removeBefore) + } + groupInfoConfig.deleteAttachmentsBefore?.let { removeAttachmentsBefore -> + mmsDatabase.deleteMessagesInThreadBeforeDate(threadId, removeAttachmentsBefore, onlyMedia = true) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 26c1fefcf8..3475c2daee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -1231,17 +1231,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (item.itemId == android.R.id.home) { return false } - return viewModel.recipient?.let { recipient -> - ConversationMenuHelper.onOptionItemSelected( - context = this, - item = item, - thread = recipient, - threadID = threadId, - factory = configFactory, - storage = storage, - groupManager = groupManagerV2, - ) - } ?: false + + return viewModel.onOptionItemSelected(this, item) } override fun block(deleteThread: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index d864904d1b..e0feabe0be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.app.Application import android.content.Context +import android.view.MenuItem import androidx.annotation.StringRes import android.widget.Toast import androidx.lifecycle.ViewModel @@ -13,11 +14,8 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -25,6 +23,7 @@ import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi @@ -40,12 +39,14 @@ import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.audio.AudioSlidePlayer +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.repository.ConversationRepository @@ -61,7 +62,9 @@ class ConversationViewModel( private val groupDb: GroupDatabase, private val threadDb: ThreadDatabase, private val lokiMessageDb: LokiMessageDatabase, - private val textSecurePreferences: TextSecurePreferences + private val textSecurePreferences: TextSecurePreferences, + private val configFactory: ConfigFactory, + private val groupManagerV2: GroupManagerV2, ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -225,7 +228,7 @@ class ConversationViewModel( */ private fun shouldShowInput(recipient: Recipient?): Boolean { return when { - recipient?.isClosedGroupV2Recipient == true -> !repository.isKicked(recipient) + recipient?.isClosedGroupV2Recipient == true -> !repository.isGroupReadOnly(recipient) recipient?.isLegacyClosedGroupRecipient == true -> { groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true } @@ -872,6 +875,37 @@ class ConversationViewModel( } } + fun onOptionItemSelected( + // This must be the context of the activity as requirement from ConversationMenuHelper + context: Context, + item: MenuItem + ): Boolean { + val recipient = recipient ?: return false + + val inProgress = ConversationMenuHelper.onOptionItemSelected( + context = context, + item = item, + thread = recipient, + threadID = threadId, + factory = configFactory, + storage = storage, + groupManager = groupManagerV2, + ) + + if (inProgress != null) { + viewModelScope.launch { + _uiState.update { it.copy(showLoader = true) } + try { + inProgress.receive() + } finally { + _uiState.update { it.copy(showLoader = false) } + } + } + } + + return true + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -890,7 +924,9 @@ class ConversationViewModel( @ApplicationContext private val context: Context, private val lokiMessageDb: LokiMessageDatabase, - private val textSecurePreferences: TextSecurePreferences + private val textSecurePreferences: TextSecurePreferences, + private val configFactory: ConfigFactory, + private val groupManagerV2: GroupManagerV2, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -904,7 +940,9 @@ class ConversationViewModel( groupDb = groupDb, threadDb = threadDb, lokiMessageDb = lokiMessageDb, - textSecurePreferences = textSecurePreferences + textSecurePreferences = textSecurePreferences, + configFactory = configFactory, + groupManagerV2 = groupManagerV2, ) as T } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index ccd3073501..fe05022937 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -20,6 +20,8 @@ import androidx.core.graphics.drawable.IconCompat import com.squareup.phrase.Phrase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.IOException @@ -156,6 +158,12 @@ object ConversationMenuHelper { }) } + /** + * Handle the selected option + * + * @return An asynchronous channel that can be used to wait for the action to complete. Null if + * the action does not require waiting. + */ fun onOptionItemSelected( context: Context, item: MenuItem, @@ -164,7 +172,7 @@ object ConversationMenuHelper { factory: ConfigFactory, storage: StorageProtocol, groupManager: GroupManagerV2, - ): Boolean { + ): ReceiveChannel? { when (item.itemId) { R.id.menu_view_all_media -> { showAllMedia(context, thread) } R.id.menu_search -> { search(context) } @@ -176,14 +184,15 @@ object ConversationMenuHelper { R.id.menu_copy_account_id -> { copyAccountID(context, thread) } R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } R.id.menu_edit_group -> { editClosedGroup(context, thread) } - R.id.menu_leave_group -> { leaveClosedGroup(context, thread, threadID, factory, storage, groupManager) } + R.id.menu_leave_group -> { return leaveClosedGroup(context, thread, threadID, factory, storage, groupManager) } R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } R.id.menu_unmute_notifications -> { unmute(context, thread) } R.id.menu_mute_notifications -> { mute(context, thread) } R.id.menu_notification_settings -> { setNotifyType(context, thread) } R.id.menu_call -> { call(context, thread) } } - return true + + return null } private fun showAllMedia(context: Context, thread: Recipient) { @@ -330,7 +339,7 @@ object ConversationMenuHelper { configFactory: ConfigFactory, storage: StorageProtocol, groupManager: GroupManagerV2, - ) { + ): ReceiveChannel? { when { thread.isLegacyClosedGroupRecipient -> { val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() @@ -357,11 +366,13 @@ object ConversationMenuHelper { thread.isClosedGroupV2Recipient -> { val accountId = AccountId(thread.address.serialize()) - val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return + val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return null val name = configFactory.withGroupConfigs(accountId) { it.groupInfo.getName() } ?: group.name + val channel = Channel() + confirmAndLeaveClosedGroup( context = context, groupName = name, @@ -369,11 +380,19 @@ object ConversationMenuHelper { threadID = threadID, storage = storage, doLeave = { - groupManager.leaveGroup(accountId, true) + try { + groupManager.leaveGroup(accountId, true) + } finally { + channel.send(Unit) + } } ) + + return channel } } + + return null } private fun confirmAndLeaveClosedGroup( diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 4df886c57a..5bca2ca524 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -365,7 +365,9 @@ class ConfigFactory @Inject constructor( } } - Unit to configs.dumpIfNeeded(clock) + configs.dumpIfNeeded(clock) + + Unit to true } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index 31411b931f..641ca9626c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -388,22 +388,20 @@ class GroupManagerV2Impl @Inject constructor( } } - override suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) { - val canSendGroupMessage = configFactory.getClosedGroup(group)?.kicked == false + override suspend fun leaveGroup(groupId: AccountId, deleteOnLeave: Boolean) { + val group = configFactory.getClosedGroup(groupId) - if (canSendGroupMessage) { - val destination = Destination.ClosedGroup(group.hexString) + // Only send the left/left notification group message when we are not kicked and we are not the only admin (only admin has a special treatment) + val weAreTheOnlyAdmin = configFactory.withGroupConfigs(groupId) { config -> + val allMembers = config.groupMembers.all() + allMembers.count { it.admin } == 1 && + allMembers.first { it.admin }.sessionId == storage.getUserPublicKey() + } - MessageSender.send( - GroupUpdated( - GroupUpdateMessage.newBuilder() - .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) - .build() - ), - destination, - isSyncMessage = false - ).await() + if (group?.kicked == false) { + val destination = Destination.ClosedGroup(groupId.hexString) + // Always send a "XXX left" message to the group if we can MessageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() @@ -412,14 +410,40 @@ class GroupManagerV2Impl @Inject constructor( ), destination, isSyncMessage = false - ).await() + ) + + // If we are not the only admin, send a left message for other admin to handle the member removal + if (!weAreTheOnlyAdmin) { + MessageSender.send( + GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) + .build() + ), + destination, + isSyncMessage = false + ).await() + } } - pollerFactory.pollerFor(group)?.stop() + // If we are the only admin, leaving this group will destroy the group + if (weAreTheOnlyAdmin) { + configFactory.withMutableGroupConfigs(groupId) { configs -> + configs.groupInfo.destroyGroup() + } + + // Must wait until the config is pushed, otherwise if we go through the rest + // of the code it will destroy the conversation, destroying the necessary configs + // along the way, we won't be able to push the "destroyed" state anymore. + configFactory.waitUntilGroupConfigsPushed(groupId) + } + + pollerFactory.pollerFor(groupId)?.stop() + if (deleteOnLeave) { - storage.getThreadId(Address.fromSerialized(group.hexString)) + storage.getThreadId(Address.fromSerialized(groupId.hexString)) ?.let(storage::deleteConversation) - configFactory.removeGroup(group) + configFactory.removeGroup(groupId) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index be56c1f899..9a956af37c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -28,6 +28,7 @@ import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getClosedGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.database.DatabaseContentProviders @@ -59,7 +60,7 @@ interface ConversationRepository { fun deleteMessages(messages: Set, threadId: Long) fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) - fun isKicked(recipient: Recipient): Boolean + fun isGroupReadOnly(recipient: Recipient): Boolean suspend fun deleteCommunityMessagesRemotely(threadId: Long, messages: Set) suspend fun delete1on1MessagesRemotely( @@ -170,15 +171,16 @@ class DefaultConversationRepository @Inject constructor( } } - override fun isKicked(recipient: Recipient): Boolean { - // For now, we only know care we are kicked for a groups v2 recipient + override fun isGroupReadOnly(recipient: Recipient): Boolean { + // We only care about group v2 recipient if (!recipient.isClosedGroupV2Recipient) { return false } + val groupId = recipient.address.serialize() return configFactory.withUserConfigs { - it.userGroups.getClosedGroup(recipient.address.serialize())?.kicked == true - } + it.userGroups.getClosedGroup(groupId)?.kicked == true + } || configFactory.withGroupConfigs(AccountId(groupId)) { it.groupInfo.isDestroyed() } } // This assumes that recipient.isContactRecipient is true diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 4468ce0fb2..5ee249daa9 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -44,7 +44,7 @@ interface GroupManagerV2 { suspend fun handleMemberLeft(message: GroupUpdated, group: AccountId) - suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) + suspend fun leaveGroup(groupId: AccountId, deleteOnLeave: Boolean) suspend fun promoteMember(group: AccountId, members: List) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 199523c3dd..33a7f8b0f6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -120,7 +120,6 @@ class JobQueue : JobDelegate { is NotifyPNServerJob, is AttachmentUploadJob, is GroupLeavingJob, - is LibSessionGroupLeavingJob, is MessageSendJob -> { txQueue.send(job) } @@ -226,7 +225,6 @@ class JobQueue : JobDelegate { RetrieveProfileAvatarJob.KEY, GroupLeavingJob.KEY, InviteContactsJob.KEY, - LibSessionGroupLeavingJob.KEY ) allJobTypes.forEach { type -> resumePendingJobs(type) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/LibSessionGroupLeavingJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/LibSessionGroupLeavingJob.kt deleted file mode 100644 index 6818623952..0000000000 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/LibSessionGroupLeavingJob.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.session.libsession.messaging.jobs - -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.utilities.Data -import org.session.libsession.messaging.utilities.UpdateMessageData -import org.session.libsignal.utilities.AccountId - -class LibSessionGroupLeavingJob(val accountId: AccountId, val deleteOnLeave: Boolean): Job { - - - override var delegate: JobDelegate? = null - override var id: String? = null - override var failureCount: Int = 0 - override val maxFailureCount: Int = 4 - - override suspend fun execute(dispatcherName: String) { - val storage = MessagingModuleConfiguration.shared.storage - // start leaving - // create message ID with leaving state - val messageId = storage.insertGroupInfoLeaving(accountId) ?: run { - delegate?.handleJobFailedPermanently( - this, - dispatcherName, - Exception("Couldn't insert GroupInfoLeaving message in leaving group job") - ) - return - } - // do actual group leave request - - // on success - val leaveGroup = kotlin.runCatching { - MessagingModuleConfiguration.shared.groupManagerV2.leaveGroup(accountId, deleteOnLeave) - } - - if (leaveGroup.isSuccess) { - // message is already deleted, succeed - delegate?.handleJobSucceeded(this, dispatcherName) - } else { - // Error leaving group, update the info message - storage.updateGroupInfoChange(messageId, UpdateMessageData.Kind.GroupErrorQuit) - } - } - - override fun serialize(): Data = - Data.Builder() - .putString(SESSION_ID_KEY, accountId.hexString) - .putBoolean(DELETE_ON_LEAVE_KEY, deleteOnLeave) - .build() - - class Factory : Job.Factory { - override fun create(data: Data): LibSessionGroupLeavingJob { - return LibSessionGroupLeavingJob( - AccountId(data.getString(SESSION_ID_KEY)), - data.getBoolean(DELETE_ON_LEAVE_KEY) - ) - } - } - - override fun getFactoryKey(): String = KEY - - companion object { - const val KEY = "LibSessionGroupLeavingJob" - private const val SESSION_ID_KEY = "SessionId" - private const val DELETE_ON_LEAVE_KEY = "DeleteOnLeave" - } - -} \ No newline at end of file