diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index ca2550b70f..b627e3ff29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -43,6 +43,7 @@ import org.jetbrains.annotations.NotNull; import org.session.libsession.avatars.AvatarHelper; import org.session.libsession.database.MessageDataProvider; import org.session.libsession.messaging.MessagingModuleConfiguration; +import org.session.libsession.messaging.groups.GroupManagerV2; import org.session.libsession.messaging.notifications.TokenFetcher; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2; @@ -164,6 +165,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Inject PushRegistrationHandler pushRegistrationHandler; @Inject TokenFetcher tokenFetcher; + @Inject GroupManagerV2 groupManagerV2; CallMessageProcessor callMessageProcessor; MessagingModuleConfiguration messagingModuleConfiguration; @@ -245,7 +247,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO configFactory, lastSentTimestampCache, this, - tokenFetcher + tokenFetcher, + groupManagerV2 ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); 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 269234b169..7883828ad8 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 @@ -77,6 +77,7 @@ import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -239,6 +240,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var viewModelFactory: ConversationViewModel.AssistedFactory @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var groupManagerV2: GroupManagerV2 private val screenshotObserver by lazy { ScreenshotObserver(this, Handler(Looper.getMainLooper())) { @@ -1218,7 +1220,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe thread = recipient, threadID = threadId, factory = configFactory, - storage = storage + storage = storage, + groupManager = groupManagerV2, ) } ?: false } 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 11040cc8c0..a7b89ae070 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 @@ -18,9 +18,14 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import com.squareup.phrase.Phrase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.IOException import network.loki.messenger.R import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.leave import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID @@ -154,7 +159,8 @@ object ConversationMenuHelper { thread: Recipient, threadID: Long, factory: ConfigFactory, - storage: StorageProtocol + storage: StorageProtocol, + groupManager: GroupManagerV2, ): Boolean { when (item.itemId) { R.id.menu_view_all_media -> { showAllMedia(context, thread) } @@ -167,7 +173,7 @@ 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) } + R.id.menu_leave_group -> { 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) } @@ -311,7 +317,8 @@ object ConversationMenuHelper { thread: Recipient, threadID: Long, configFactory: ConfigFactory, - storage: StorageProtocol + storage: StorageProtocol, + groupManager: GroupManagerV2, ) { when { thread.isLegacyClosedGroupRecipient -> { @@ -351,7 +358,7 @@ object ConversationMenuHelper { threadID = threadID, storage = storage, doLeave = { - check(storage.leaveGroup(accountId.hexString, true)) + groupManager.leaveGroup(accountId, true) } ) } @@ -364,7 +371,7 @@ object ConversationMenuHelper { isAdmin: Boolean, threadID: Long, storage: StorageProtocol, - doLeave: () -> Unit, + doLeave: suspend () -> Unit, ) { val message = if (isAdmin) { Phrase.from(context, R.string.groupDeleteDescription) @@ -387,14 +394,19 @@ object ConversationMenuHelper { title(R.string.groupLeave) text(message) dangerButton(R.string.leave) { - try { - // Cancel any outstanding jobs - storage.cancelPendingMessageSendJobs(threadID) + GlobalScope.launch(Dispatchers.Default) { + try { + // Cancel any outstanding jobs + storage.cancelPendingMessageSendJobs(threadID) - doLeave() - } catch (e: Exception) { - onLeaveFailed() + doLeave() + } catch (e: Exception) { + withContext(Dispatchers.Main) { + onLeaveFailed() + } + } } + } button(R.string.cancel) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 59bd4671b3..609da9b67f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -5,7 +5,6 @@ import android.net.Uri import com.google.protobuf.ByteString import com.goterl.lazysodium.utils.KeyPair import network.loki.messenger.libsession_util.Config -import network.loki.messenger.R import java.security.MessageDigest import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED @@ -1398,16 +1397,12 @@ open class Storage( MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString)) } else { // Update our on member state - configFactory.getGroupMemberConfig(groupSessionId)?.use { members -> - configFactory.getGroupInfoConfig(groupSessionId)?.use { info -> - configFactory.getGroupKeysConfig(groupSessionId, info)?.use { keys -> - members.get(getUserPublicKey().orEmpty())?.let { member -> - members.set(member.setPromoteSuccess().setInvited()) - } - - configFactory.saveGroupConfigs(keys, info, members) - } + configFactory.withGroupConfigsOrNull(groupSessionId) { info, members, keys -> + members.get(getUserPublicKey().orEmpty())?.let { member -> + members.set(member.setPromoteSuccess().setInvited()) } + + configFactory.saveGroupConfigs(keys, info, members) } } @@ -1538,135 +1533,6 @@ open class Storage( } } - override fun inviteClosedGroupMembers(groupSessionId: String, invitees: List) { - // don't try to process invitee acceptance if we aren't admin - if (configFactory.userGroups?.getClosedGroup(groupSessionId)?.hasAdminKey() != true) return - val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return - val accountId = AccountId(groupSessionId) - val membersConfig = configFactory.getGroupMemberConfig(accountId) ?: return - val infoConfig = configFactory.getGroupInfoConfig(accountId) ?: return - val groupAuth = OwnedSwarmAuth.ofClosedGroup(accountId, adminKey) - - // Filter out people who aren't already invited - val filteredMembers = invitees.filter { - membersConfig.get(it) == null - } - // Create each member's contact info if we have it - filteredMembers.forEach { memberSessionId -> - val contact = getContactWithAccountID(memberSessionId) - val name = contact?.name - val url = contact?.profilePictureURL - val key = contact?.profilePictureEncryptionKey - val userPic = if (url != null && key != null) { - UserPic(url, key) - } else UserPic.DEFAULT - val member = membersConfig.getOrConstruct(memberSessionId).copy( - name = name, - profilePicture = userPic, - ).setInvited() - membersConfig.set(member) - } - - // Persist the config changes now, so we can show the invite status immediately - configFactory.persistGroupConfigDump(membersConfig, accountId, SnodeAPI.nowWithOffset) - - // re-key for new members - val keysConfig = configFactory.getGroupKeysConfig( - accountId, - info = infoConfig, - members = membersConfig, - free = false - ) ?: return - - keysConfig.rekey(infoConfig, membersConfig) - - // build unrevocation, in case of re-adding members - val membersToUnrevoke = filteredMembers.map { keysConfig.getSubAccountToken(AccountId(it)) } - val unrevocation = if (membersToUnrevoke.isNotEmpty()) { - SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( - groupAdminAuth = groupAuth, - subAccountTokens = membersToUnrevoke - ) ?: return Log.e("ClosedGroup", "Failed to build revocation update") - } else { - null - } - - // Build and store the key update in group swarm - val toDelete = mutableListOf() - - val keyMessage = keysConfig.messageInformation(groupAuth) - val infoMessage = infoConfig.messageInformation(toDelete, groupAuth) - val membersMessage = membersConfig.messageInformation(toDelete, groupAuth) - - val delete = SnodeAPI.buildAuthenticatedDeleteBatchInfo( - auth = groupAuth, - messageHashes = toDelete, - ) - - val requests = buildList { - add(keyMessage.batch) - add(infoMessage.batch) - add(membersMessage.batch) - - if (unrevocation != null) { - add(unrevocation) - } - - add(delete) - } - - val response = SnodeAPI.getSingleTargetSnode(groupSessionId).bind { snode -> - SnodeAPI.getRawBatchResponse( - snode, - groupSessionId, - requests, - sequence = true - ) - } - - try { - val rawResponse = response.get() - val results = (rawResponse["results"] as ArrayList).first() as Map - if (results["code"] as Int != 200) { - throw Exception("Response wasn't successful for unrevoke and key update: ${results["body"] as? String}") - } - - configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig) - - val job = InviteContactsJob(groupSessionId, filteredMembers.toTypedArray()) - JobQueue.shared.add(job) - - val timestamp = SnodeAPI.nowWithOffset - val signature = SodiumUtilities.sign( - buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), - adminKey - ) - val updatedMessage = GroupUpdated( - GroupUpdateMessage.newBuilder() - .setMemberChangeMessage( - GroupUpdateMemberChangeMessage.newBuilder() - .addAllMemberSessionIds(filteredMembers) - .setType(GroupUpdateMemberChangeMessage.Type.ADDED) - .setAdminSignature(ByteString.copyFrom(signature)) - ) - .build() - ).apply { this.sentTimestamp = timestamp } - MessageSender.send(updatedMessage, fromSerialized(groupSessionId)) - insertGroupInfoChange(updatedMessage, accountId) - infoConfig.free() - membersConfig.free() - keysConfig.free() - } catch (e: Exception) { - Log.e("ClosedGroup", "Failed to store new key", e) - infoConfig.free() - membersConfig.free() - keysConfig.free() - // toaster toast here - return - } - - } - override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? { val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset val senderPublicKey = message.sender @@ -1739,262 +1605,6 @@ open class Storage( } } - override fun promoteMember(groupAccountId: AccountId, promotions: List) { - val adminKey = configFactory.userGroups?.getClosedGroup(groupAccountId.hexString)?.adminKey ?: return - if (adminKey.isEmpty()) { - return Log.e("ClosedGroup", "No admin key for group") - } - - configFactory.withGroupConfigsOrNull(groupAccountId) { info, members, keys -> - promotions.forEach { accountId -> - val promoted = members.get(accountId.hexString)?.setPromoteSent() ?: return@forEach - members.set(promoted) - - val message = GroupUpdated( - GroupUpdateMessage.newBuilder() - .setPromoteMessage( - DataMessage.GroupUpdatePromoteMessage.newBuilder() - .setGroupIdentitySeed(ByteString.copyFrom(adminKey)) - .setName(info.getName()) - ) - .build() - ) - MessageSender.send(message, fromSerialized(accountId.hexString)) - } - - configFactory.saveGroupConfigs(keys, info, members) - } - - - val groupDestination = Destination.ClosedGroup(groupAccountId.hexString) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) - val timestamp = SnodeAPI.nowWithOffset - val signature = SodiumUtilities.sign( - buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), - adminKey - ) - val message = GroupUpdated( - GroupUpdateMessage.newBuilder() - .setMemberChangeMessage( - GroupUpdateMemberChangeMessage.newBuilder() - .addAllMemberSessionIds(promotions.map { it.hexString }) - .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED) - .setAdminSignature(ByteString.copyFrom(signature)) - ) - .build() - ).apply { - sentTimestamp = timestamp - } - - MessageSender.send(message, fromSerialized(groupDestination.publicKey)) - insertGroupInfoChange(message, groupAccountId) - } - - private suspend fun doRemoveMember( - groupSessionId: AccountId, - removedMembers: List, - sendRemovedMessage: Boolean, - removeMemberMessages: Boolean, - ) { - val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId.hexString)?.adminKey - if (adminKey == null || adminKey.isEmpty()) { - return Log.e("ClosedGroup", "No admin key for group") - } - - val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupSessionId, adminKey) - - configFactory.withGroupConfigsOrNull(groupSessionId) { info, members, keys -> - // To remove a member from a group, we need to first: - // 1. Notify the swarm that this member's key has bene revoked - // 2. Send a "kicked" message to a special namespace that the kicked member can still read - // 3. Optionally, send "delete member messages" to the group. (So that every device in the group - // delete this member's messages locally.) - // These three steps will be included in a sequential call as they all need to be done in order. - // After these steps are all done, we will do the following: - // Update the group configs to remove the member, sync if needed, then - // delete the member's messages locally and remotely. - val messageSendTimestamp = SnodeAPI.nowWithOffset - - val essentialRequests = buildList { - this += SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( - groupAdminAuth = groupAuth, - subAccountTokens = removedMembers.map(keys::getSubAccountToken) - ) - - this += Sodium.encryptForMultipleSimple( - messages = removedMembers.map{"${it.hexString}-${keys.currentGeneration()}".encodeToByteArray()}.toTypedArray(), - recipients = removedMembers.map { it.pubKeyBytes }.toTypedArray(), - ed25519SecretKey = adminKey, - domain = Sodium.KICKED_DOMAIN - ).let { encryptedForMembers -> - buildAuthenticatedStoreBatchInfo( - namespace = Namespace.REVOKED_GROUP_MESSAGES(), - message = SnodeMessage( - recipient = groupSessionId.hexString, - data = Base64.encodeBytes(encryptedForMembers), - ttl = SnodeMessage.CONFIG_TTL, - timestamp = messageSendTimestamp - ), - auth = groupAuth - ) - } - - if (removeMemberMessages) { - val adminSignature = - SodiumUtilities.sign(buildDeleteMemberContentSignature( - memberIds = removedMembers, - messageHashes = emptyList(), - timestamp = messageSendTimestamp - ), adminKey) - - this += buildAuthenticatedStoreBatchInfo( - namespace = Namespace.CLOSED_GROUP_MESSAGES(), - message = MessageSender.buildWrappedMessageToSnode( - destination = Destination.ClosedGroup(groupSessionId.hexString), - message = GroupUpdated(GroupUpdateMessage.newBuilder() - .setDeleteMemberContent( - GroupUpdateDeleteMemberContentMessage.newBuilder() - .addAllMemberSessionIds(removedMembers.map { it.hexString }) - .setAdminSignature(ByteString.copyFrom(adminSignature)) - ) - .build() - ).apply { sentTimestamp = messageSendTimestamp }, - isSyncMessage = false - ), - auth = groupAuth - ) - } - } - - val snode = SnodeAPI.getSingleTargetSnode(groupSessionId.hexString).await() - val responses = SnodeAPI.getBatchResponse(snode, groupSessionId.hexString, essentialRequests, sequence = true) - - require(responses.results.all { it.code == 200 }) { - "Failed to execute essential steps for removing member" - } - - // Next step: update group configs, rekey, remove member messages if required - val messagesToDelete = mutableListOf() - for (member in removedMembers) { - members.erase(member.hexString) - } - - keys.rekey(info, members) - - if (removeMemberMessages) { - val threadId = getThreadId(fromSerialized(groupSessionId.hexString)) - if (threadId != null) { - val component = DatabaseComponent.get(context) - val mmsSmsDatabase = component.mmsSmsDatabase() - val lokiDb = component.lokiMessageDatabase() - for (member in removedMembers) { - for (msg in mmsSmsDatabase.getUserMessages(threadId, member.hexString)) { - val serverHash = lokiDb.getMessageServerHash(msg.id, msg.isMms) - if (serverHash != null) { - messagesToDelete.add(serverHash) - } - } - - deleteMessagesByUser(threadId, member.hexString) - } - } - } - - val requests = buildList { - this += "Sync keys config messages" to keys.messageInformation(groupAuth).batch - this += "Sync info config messages" to info.messageInformation(messagesToDelete, groupAuth).batch - this += "Sync member config messages" to members.messageInformation(messagesToDelete, groupAuth).batch - this += "Delete outdated config and member messages" to buildAuthenticatedDeleteBatchInfo(groupAuth, messagesToDelete) - } - - val response = SnodeAPI.getBatchResponse( - snode = snode, - publicKey = groupSessionId.hexString, - requests = requests.map { it.second } - ) - - if (responses.results.any { it.code != 200 }) { - val errors = responses.results.mapIndexedNotNull { index, item -> - if (item.code != 200) { - requests[index].first - } else { - null - } - } - - Log.e(TAG, "Failed to execute some steps for removing member: $errors") - } - - // Persist the changes - configFactory.saveGroupConfigs(keys, info, members) - - if (sendRemovedMessage) { - val timestamp = messageSendTimestamp - val signature = SodiumUtilities.sign( - buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp), - adminKey - ) - - val updateMessage = GroupUpdateMessage.newBuilder() - .setMemberChangeMessage( - GroupUpdateMemberChangeMessage.newBuilder() - .addAllMemberSessionIds(removedMembers.map { it.hexString }) - .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) - .setAdminSignature(ByteString.copyFrom(signature)) - ) - .build() - val message = GroupUpdated( - updateMessage - ).apply { sentTimestamp = timestamp } - MessageSender.send(message, Destination.ClosedGroup(groupSessionId.hexString), false) - insertGroupInfoChange(message, groupSessionId) - } - } - - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded( - Destination.ClosedGroup(groupSessionId.hexString) - ) - } - - override suspend fun removeMember( - groupAccountId: AccountId, - removedMembers: List, - removeMessages: Boolean - ) { - doRemoveMember( - groupAccountId, - removedMembers, - sendRemovedMessage = true, - removeMemberMessages = removeMessages - ) - } - - override suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) { - val userGroups = configFactory.userGroups ?: return - val closedGroupHexString = closedGroupId.hexString - val closedGroup = userGroups.getClosedGroup(closedGroupId.hexString) ?: return - if (closedGroup.hasAdminKey()) { - // re-key and do a new config removing the previous member - doRemoveMember( - closedGroupId, - listOf(AccountId(message.sender!!)), - sendRemovedMessage = false, - removeMemberMessages = false - ) - } else { - configFactory.getGroupMemberConfig(closedGroupId)?.use { memberConfig -> - // if the leaving member is an admin, disable the group and remove it - // This is just to emulate the "existing" group behaviour, this will need to be removed in future - if (memberConfig.get(message.sender!!)?.admin == true) { - pollerFactory.pollerFor(closedGroupId)?.stop() - getThreadId(fromSerialized(closedGroupHexString))?.let { threadId -> - deleteConversation(threadId) - } - configFactory.removeGroup(closedGroupId) - } - } - } - } override fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId) { insertGroupInfoChange(message, closedGroupId) @@ -2004,49 +1614,6 @@ open class Storage( pollerFactory.pollerFor(groupAccountId)?.stop() } - override fun leaveGroup(groupSessionId: String, deleteOnLeave: Boolean): Boolean { - val closedGroupId = AccountId(groupSessionId) - val canSendGroupMessage = configFactory.userGroups?.getClosedGroup(groupSessionId)?.kicked != true - - try { - if (canSendGroupMessage) { - // throws on unsuccessful send - MessageSender.sendNonDurably( - message = GroupUpdated( - GroupUpdateMessage.newBuilder() - .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) - .build() - ), - address = fromSerialized(groupSessionId), - isSyncMessage = false - ).get() - - MessageSender.sendNonDurably( - message = GroupUpdated( - GroupUpdateMessage.newBuilder() - .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) - .build() - ), - address = fromSerialized(groupSessionId), - isSyncMessage = false - ).get() - } - - pollerFactory.pollerFor(closedGroupId)?.stop() - // TODO: set "deleted" and post to -10 group namespace? - if (deleteOnLeave) { - getThreadId(fromSerialized(groupSessionId))?.let { threadId -> - deleteConversation(threadId) - } - configFactory.removeGroup(closedGroupId) - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } - } catch (e: Exception) { - Log.e("ClosedGroup", "Failed to send leave group message", e) - return false - } - return true - } override fun setName(groupSessionId: String, newName: String) { val closedGroupId = AccountId(groupSessionId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index ea81223a33..90f9c03932 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -9,9 +9,11 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.Toaster +import org.thoughtcrime.securesms.groups.GroupManagerV2Impl import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository import javax.inject.Singleton @@ -25,6 +27,9 @@ abstract class AppModule { @Binds abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository + + @Binds + abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2 } @Module diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt index dbc22057e9..03435e8d90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt @@ -6,14 +6,11 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -23,6 +20,7 @@ import network.loki.messenger.libsession_util.util.GroupDisplayInfo import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.jobs.InviteContactsJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsignal.utilities.AccountId @@ -34,7 +32,8 @@ const val MAX_GROUP_NAME_LENGTH = 100 class EditGroupViewModel @AssistedInject constructor( @Assisted private val groupSessionId: String, private val storage: StorageProtocol, - configFactory: ConfigFactory + configFactory: ConfigFactory, + private val groupManager: GroupManagerV2, ) : ViewModel() { // Input/Output state private val mutableEditingName = MutableStateFlow(null) @@ -165,8 +164,12 @@ class EditGroupViewModel @AssistedInject constructor( } fun onContactSelected(contacts: Set) { - viewModelScope.launch(Dispatchers.Default) { - storage.inviteClosedGroupMembers(groupSessionId, contacts.map { it.accountID }) + performGroupOperation { + groupManager.inviteMembers( + AccountId(hexString = groupSessionId), + contacts.map { AccountId(it.accountID) }, + shareHistory = true + ) } } @@ -177,33 +180,18 @@ class EditGroupViewModel @AssistedInject constructor( } fun onPromoteContact(memberSessionId: String) { - viewModelScope.launch(Dispatchers.Default) { - storage.promoteMember(AccountId(groupSessionId), listOf(AccountId(memberSessionId))) + performGroupOperation { + groupManager.promoteMember(AccountId(groupSessionId), listOf(AccountId(memberSessionId))) } } fun onRemoveContact(contactSessionId: String, removeMessages: Boolean) { - viewModelScope.launch { - mutableInProgress.value = true - - // We need to use GlobalScope here because we don't want - // "removeMember" to be cancelled when the view model is cleared. This operation - // is expected to complete even if the view model is cleared. - val task = GlobalScope.launch { - storage.removeMember( - groupAccountId = AccountId(groupSessionId), - removedMembers = listOf(AccountId(contactSessionId)), - removeMessages = removeMessages - ) - } - - try { - task.join() - } catch (e: Exception) { - mutableError.value = e.localizedMessage.orEmpty() - } finally { - mutableInProgress.value = false - } + performGroupOperation { + groupManager.removeMembers( + groupAccountId = AccountId(groupSessionId), + removedMembers = listOf(AccountId(contactSessionId)), + removeMessages = removeMessages + ) } } @@ -240,6 +228,32 @@ class EditGroupViewModel @AssistedInject constructor( mutableError.value = null } + /** + * Perform a group operation, such as inviting a member, removing a member. + * + * This is a helper function that encapsulates the common error handling and progress tracking. + */ + private fun performGroupOperation(operation: suspend () -> Unit) { + viewModelScope.launch { + mutableInProgress.value = true + + // We need to use GlobalScope here because we don't want + // "removeMember" to be cancelled when the view model is cleared. This operation + // is expected to complete even if the view model is cleared. + val task = GlobalScope.launch { + operation() + } + + try { + task.join() + } catch (e: Exception) { + mutableError.value = e.localizedMessage.orEmpty() + } finally { + mutableInProgress.value = false + } + } + } + @AssistedFactory interface Factory { fun create(groupSessionId: String): EditGroupViewModel diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt new file mode 100644 index 0000000000..fcda98f347 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -0,0 +1,464 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import com.google.protobuf.ByteString +import dagger.hilt.android.qualifiers.ApplicationContext +import network.loki.messenger.libsession_util.util.INVITE_STATUS_SENT +import network.loki.messenger.libsession_util.util.Sodium +import network.loki.messenger.libsession_util.util.UserPic +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.jobs.ConfigurationSyncJob.Companion.messageInformation +import org.session.libsession.messaging.jobs.InviteContactsJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.snode.OwnedSwarmAuth +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeMessage +import org.session.libsession.snode.model.BatchResponse +import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.withGroupConfigsOrNull +import org.session.libsignal.protos.SignalServiceProtos.DataMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Namespace +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.PollerFactory +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GroupManagerV2Impl @Inject constructor( + val storage: StorageProtocol, + val configFactory: ConfigFactory, + val mmsSmsDatabase: MmsSmsDatabase, + val lokiDatabase: LokiMessageDatabase, + val pollerFactory: PollerFactory, + @ApplicationContext val application: Context, +) : GroupManagerV2 { + /** + * Require admin access to a group, and return the admin key. + * + * @throws IllegalArgumentException if the group does not exist or no admin key is found. + */ + private fun requireAdminAccess(group: AccountId): ByteArray { + return checkNotNull(configFactory + .userGroups + ?.getClosedGroup(group.hexString) + ?.adminKey + ?.takeIf { it.isNotEmpty() }) { "Only admin is allowed to invite members" } + } + + override suspend fun inviteMembers( + group: AccountId, + newMembers: List, + shareHistory: Boolean + ) { + val adminKey = requireAdminAccess(group) + val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) + + configFactory.withGroupConfigsOrNull(group) { infoConfig, membersConfig, keysConfig -> + // Construct the new members in the config + for (newMember in newMembers) { + val toSet = membersConfig.get(newMember.hexString) + ?.let { existing -> + if (existing.inviteFailed || existing.invitePending) { + existing.copy(inviteStatus = INVITE_STATUS_SENT, supplement = shareHistory) + } else { + existing + } + } + ?: membersConfig.getOrConstruct(newMember.hexString).let { + val contact = storage.getContactWithAccountID(newMember.hexString) + it.copy( + name = contact?.name, + profilePicture = contact?.profilePicture ?: UserPic.DEFAULT, + inviteStatus = INVITE_STATUS_SENT, + supplement = shareHistory + ) + } + + membersConfig.set(toSet) + } + + // Persist the member change to the db now for the UI to reflect the status change + val timestamp = SnodeAPI.nowWithOffset + configFactory.persistGroupConfigDump(membersConfig, group, timestamp) + + val batchRequests = mutableListOf() + val messagesToDelete = mutableListOf() // List of message hashes + + // Depends on whether we want to share history, we may need to rekey or just adding supplement keys + if (shareHistory) { + for (member in newMembers) { + val memberKey = keysConfig.supplementFor(member.hexString) + batchRequests.add(SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = keysConfig.namespace(), + message = SnodeMessage( + recipient = group.hexString, + data = Base64.encodeBytes(memberKey), + ttl = SnodeMessage.CONFIG_TTL, + timestamp = timestamp + ), + auth = groupAuth, + )) + } + } else { + keysConfig.rekey(infoConfig, membersConfig) + } + + // Call un-revocate API on new members, in case they have been removed before + batchRequests += SnodeAPI.buildAuthenticatedUnrevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = newMembers.map(keysConfig::getSubAccountToken) + ) + + keysConfig.messageInformation(groupAuth)?.let { + batchRequests += it.batch + } + batchRequests += infoConfig.messageInformation(messagesToDelete, groupAuth).batch + batchRequests += membersConfig.messageInformation(messagesToDelete, groupAuth).batch + + if (messagesToDelete.isNotEmpty()) { + batchRequests += SnodeAPI.buildAuthenticatedDeleteBatchInfo( + auth = groupAuth, + messageHashes = messagesToDelete + ) + } + + // Call the API + val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() + val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) + + // Make sure every request is successful + response.requireAllRequestsSuccessful("Failed to invite members") + + // Persist the keys config + configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig) + + // Send the invitation message to the new members + JobQueue.shared.add(InviteContactsJob(group.hexString, newMembers.map { it.hexString }.toTypedArray())) + + // Send a member change message to the group + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.ADDED, timestamp), + adminKey + ) + + val updatedMessage = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(newMembers.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.ADDED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { this.sentTimestamp = timestamp } + MessageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) + storage.insertGroupInfoChange(updatedMessage, group) + + group + } + } + + + override suspend fun removeMembers( + groupAccountId: AccountId, + removedMembers: List, + removeMessages: Boolean + ) { + doRemoveMembers( + group = groupAccountId, + removedMembers = removedMembers, + sendRemovedMessage = true, + removeMemberMessages = removeMessages + ) + } + + override suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) { + val userGroups = configFactory.userGroups ?: return + val closedGroupHexString = closedGroupId.hexString + val closedGroup = userGroups.getClosedGroup(closedGroupId.hexString) ?: return + if (closedGroup.hasAdminKey()) { + // re-key and do a new config removing the previous member + doRemoveMembers( + closedGroupId, + listOf(AccountId(message.sender!!)), + sendRemovedMessage = false, + removeMemberMessages = false + ) + } else { + configFactory.getGroupMemberConfig(closedGroupId)?.use { memberConfig -> + // if the leaving member is an admin, disable the group and remove it + // This is just to emulate the "existing" group behaviour, this will need to be removed in future + if (memberConfig.get(message.sender!!)?.admin == true) { + pollerFactory.pollerFor(closedGroupId)?.stop() + storage.getThreadId(Address.fromSerialized(closedGroupHexString)) + ?.let(storage::deleteConversation) + configFactory.removeGroup(closedGroupId) + } + } + } + } + + override suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) { + val canSendGroupMessage = configFactory.userGroups?.getClosedGroup(group.hexString)?.kicked != true + val address = Address.fromSerialized(group.hexString) + + if (canSendGroupMessage) { + MessageSender.sendNonDurably( + message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) + .build() + ), + address = address, + isSyncMessage = false + ).await() + + MessageSender.sendNonDurably( + message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) + .build() + ), + address = address, + isSyncMessage = false + ).await() + } + + pollerFactory.pollerFor(group)?.stop() + // TODO: set "deleted" and post to -10 group namespace? + if (deleteOnLeave) { + storage.getThreadId(address)?.let(storage::deleteConversation) + configFactory.removeGroup(group) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application) + } + } + + override suspend fun promoteMember(group: AccountId, members: List) { + val adminKey = requireAdminAccess(group) + + configFactory.withGroupConfigsOrNull(group) { info, membersConfig, keys -> + for (member in members) { + val promoted = membersConfig.get(member.hexString)?.setPromoteSent() ?: continue + membersConfig.set(promoted) + + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setPromoteMessage( + DataMessage.GroupUpdatePromoteMessage.newBuilder() + .setGroupIdentitySeed(ByteString.copyFrom(adminKey)) + .setName(info.getName()) + ) + .build() + ) + MessageSender.send(message, Address.fromSerialized(group.hexString)) + } + + configFactory.saveGroupConfigs(keys, info, membersConfig) + } + + + val groupDestination = Destination.ClosedGroup(group.hexString) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) + val timestamp = SnodeAPI.nowWithOffset + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.PROMOTED, timestamp), + adminKey + ) + val message = GroupUpdated( + GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(members.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.PROMOTED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + ).apply { + sentTimestamp = timestamp + } + + MessageSender.send(message, Address.fromSerialized(groupDestination.publicKey)) + storage.insertGroupInfoChange(message, group) + } + + private suspend fun doRemoveMembers(group: AccountId, + removedMembers: List, + sendRemovedMessage: Boolean, + removeMemberMessages: Boolean) { + val adminKey = requireAdminAccess(group) + val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) + + configFactory.withGroupConfigsOrNull(group) { info, members, keys -> + // To remove a member from a group, we need to first: + // 1. Notify the swarm that this member's key has bene revoked + // 2. Send a "kicked" message to a special namespace that the kicked member can still read + // 3. Optionally, send "delete member messages" to the group. (So that every device in the group + // delete this member's messages locally.) + // These three steps will be included in a sequential call as they all need to be done in order. + // After these steps are all done, we will do the following: + // Update the group configs to remove the member, sync if needed, then + // delete the member's messages locally and remotely. + val messageSendTimestamp = SnodeAPI.nowWithOffset + + val essentialRequests = buildList { + this += SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( + groupAdminAuth = groupAuth, + subAccountTokens = removedMembers.map(keys::getSubAccountToken) + ) + + this += Sodium.encryptForMultipleSimple( + messages = removedMembers.map{"${it.hexString}-${keys.currentGeneration()}".encodeToByteArray()}.toTypedArray(), + recipients = removedMembers.map { it.pubKeyBytes }.toTypedArray(), + ed25519SecretKey = adminKey, + domain = Sodium.KICKED_DOMAIN + ).let { encryptedForMembers -> + SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = Namespace.REVOKED_GROUP_MESSAGES(), + message = SnodeMessage( + recipient = group.hexString, + data = Base64.encodeBytes(encryptedForMembers), + ttl = SnodeMessage.CONFIG_TTL, + timestamp = messageSendTimestamp + ), + auth = groupAuth + ) + } + + if (removeMemberMessages) { + val adminSignature = + SodiumUtilities.sign( + buildDeleteMemberContentSignature( + memberIds = removedMembers, + messageHashes = emptyList(), + timestamp = messageSendTimestamp + ), adminKey) + + this += SnodeAPI.buildAuthenticatedStoreBatchInfo( + namespace = Namespace.CLOSED_GROUP_MESSAGES(), + message = MessageSender.buildWrappedMessageToSnode( + destination = Destination.ClosedGroup(group.hexString), + message = GroupUpdated(GroupUpdateMessage.newBuilder() + .setDeleteMemberContent( + GroupUpdateDeleteMemberContentMessage.newBuilder() + .addAllMemberSessionIds(removedMembers.map { it.hexString }) + .setAdminSignature(ByteString.copyFrom(adminSignature)) + ) + .build() + ).apply { sentTimestamp = messageSendTimestamp }, + isSyncMessage = false + ), + auth = groupAuth + ) + } + } + + val snode = SnodeAPI.getSingleTargetSnode(group.hexString).await() + val responses = SnodeAPI.getBatchResponse(snode, group.hexString, essentialRequests, sequence = true) + + responses.requireAllRequestsSuccessful("Failed to execute essential steps for removing member") + + // Next step: update group configs, rekey, remove member messages if required + val messagesToDelete = mutableListOf() + for (member in removedMembers) { + members.erase(member.hexString) + } + + keys.rekey(info, members) + + if (removeMemberMessages) { + val threadId = storage.getThreadId(Address.fromSerialized(group.hexString)) + if (threadId != null) { + for (member in removedMembers) { + for (msg in mmsSmsDatabase.getUserMessages(threadId, member.hexString)) { + val serverHash = lokiDatabase.getMessageServerHash(msg.id, msg.isMms) + if (serverHash != null) { + messagesToDelete.add(serverHash) + } + } + + storage.deleteMessagesByUser(threadId, member.hexString) + } + } + } + + val requests = buildList { + keys.messageInformation(groupAuth)?.let { + this += "Sync keys config messages" to it.batch + } + + this += "Sync info config messages" to info.messageInformation(messagesToDelete, groupAuth).batch + this += "Sync member config messages" to members.messageInformation(messagesToDelete, groupAuth).batch + this += "Delete outdated config and member messages" to SnodeAPI.buildAuthenticatedDeleteBatchInfo(groupAuth, messagesToDelete) + } + + val response = SnodeAPI.getBatchResponse( + snode = snode, + publicKey = group.hexString, + requests = requests.map { it.second } + ) + + response.requireAllRequestsSuccessful("Failed to remove members") + + // Persist the changes + configFactory.saveGroupConfigs(keys, info, members) + + if (sendRemovedMessage) { + val timestamp = messageSendTimestamp + val signature = SodiumUtilities.sign( + buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp), + adminKey + ) + + val updateMessage = GroupUpdateMessage.newBuilder() + .setMemberChangeMessage( + GroupUpdateMemberChangeMessage.newBuilder() + .addAllMemberSessionIds(removedMembers.map { it.hexString }) + .setType(GroupUpdateMemberChangeMessage.Type.REMOVED) + .setAdminSignature(ByteString.copyFrom(signature)) + ) + .build() + val message = GroupUpdated( + updateMessage + ).apply { sentTimestamp = timestamp } + MessageSender.send(message, Destination.ClosedGroup(group.hexString), false) + storage.insertGroupInfoChange(message, group) + } + } + + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded( + Destination.ClosedGroup(group.hexString) + ) + } + + private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { + val firstError = this.results.firstOrNull { it.code != 200 } + require(firstError == null) { "$errorMessage: ${firstError!!.body}" } + } + + private val Contact.profilePicture: UserPic? get() { + val url = this.profilePictureURL + val key = this.profilePictureEncryptionKey + return if (url != null && key != null) { + UserPic(url, key) + } else { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 82c623f27a..c6a4e92272 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -73,6 +73,7 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import com.bumptech.glide.Glide import com.bumptech.glide.RequestManager +import org.session.libsession.messaging.groups.GroupManagerV2 import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity @@ -118,6 +119,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var groupManagerV2: GroupManagerV2 private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -600,7 +602,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), thread = recipient, threadID = threadID, configFactory = configFactory, - storage = storage + storage = storage, + groupManager = groupManagerV2, ) return diff --git a/libsession-util/libsession-util b/libsession-util/libsession-util index 0193c36e0d..995e22dcbf 160000 --- a/libsession-util/libsession-util +++ b/libsession-util/libsession-util @@ -1 +1 @@ -Subproject commit 0193c36e0dad461385d6407a00f33b7314e6d740 +Subproject commit 995e22dcbf08b3cb9e2ad595859e4cd9a4ed8776 diff --git a/libsession-util/src/main/cpp/group_members.cpp b/libsession-util/src/main/cpp/group_members.cpp index 128af66dec..a47b6068ab 100644 --- a/libsession-util/src/main/cpp/group_members.cpp +++ b/libsession-util/src/main/cpp/group_members.cpp @@ -100,4 +100,5 @@ Java_network_loki_messenger_libsession_1util_GroupMembersConfig_set(JNIEnv *env, auto config = ptrToMembers(env, thiz); auto deserialized = util::deserialize_group_member(env, group_member); config->set(deserialized); -} \ No newline at end of file +} + diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index c5168a9294..223f892e13 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -177,16 +177,11 @@ interface StorageProtocol { fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: AccountId) fun getLibSessionClosedGroup(groupAccountId: String): GroupInfo.ClosedGroupInfo? fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo? - fun inviteClosedGroupMembers(groupAccountId: String, invitees: List) fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? fun insertGroupInfoLeaving(closedGroup: AccountId): Long? fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) - fun promoteMember(groupAccountId: AccountId, promotions: List) - suspend fun removeMember(groupAccountId: AccountId, removedMembers: List, removeMessages: Boolean) - suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId) fun handleKicked(groupAccountId: AccountId) - fun leaveGroup(groupSessionId: String, deleteOnLeave: Boolean): Boolean fun setName(groupSessionId: String, newName: String) fun sendGroupUpdateDeleteMessage(groupSessionId: String, messageHashes: List): Promise diff --git a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index bb5fd3fb3b..f56b74d8b0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -4,6 +4,7 @@ import android.content.Context import com.goterl.lazysodium.utils.KeyPair import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.utilities.ConfigFactoryProtocol @@ -20,6 +21,7 @@ class MessagingModuleConfiguration( val lastSentTimestampCache: LastSentTimestampCache, val toaster: Toaster, val tokenFetcher: TokenFetcher, + val groupManagerV2: GroupManagerV2, ) { companion object { 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 new file mode 100644 index 0000000000..8eeccce3f3 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -0,0 +1,28 @@ +package org.session.libsession.messaging.groups + +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsignal.utilities.AccountId + +/** + * Business logic handling group v2 operations like inviting members, + * removing members, promoting members, leaving groups, etc. + */ +interface GroupManagerV2 { + suspend fun inviteMembers( + group: AccountId, + newMembers: List, + shareHistory: Boolean + ) + + suspend fun removeMembers( + groupAccountId: AccountId, + removedMembers: List, + removeMessages: Boolean + ) + + suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) + + suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) + + suspend fun promoteMember(group: AccountId, members: List) +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt index cb9e41279b..59f5149faf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -294,12 +294,14 @@ data class ConfigurationSyncJob(val destination: Destination) : Job { ) } - fun GroupKeysConfig.messageInformation(auth: OwnedSwarmAuth): ConfigMessageInformation { + fun GroupKeysConfig.messageInformation(auth: OwnedSwarmAuth): ConfigMessageInformation? { + val pending = pendingConfig() ?: return null + val sentTimestamp = SnodeAPI.nowWithOffset val message = SnodeMessage( auth.accountId.hexString, - Base64.encodeBytes(pendingConfig()!!), // should not be null from checking has pending + Base64.encodeBytes(pending), SnodeMessage.CONFIG_TTL, sentTimestamp ) 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 index dd3c0782d9..6818623952 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/LibSessionGroupLeavingJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/LibSessionGroupLeavingJob.kt @@ -28,7 +28,11 @@ class LibSessionGroupLeavingJob(val accountId: AccountId, val deleteOnLeave: Boo // do actual group leave request // on success - if (storage.leaveGroup(accountId.hexString, deleteOnLeave)) { + val leaveGroup = kotlin.runCatching { + MessagingModuleConfiguration.shared.groupManagerV2.leaveGroup(accountId, deleteOnLeave) + } + + if (leaveGroup.isSuccess) { // message is already deleted, succeed delegate?.handleJobSucceeded(this, dispatcherName) } else { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index b1acf71bfd..f0370aa4cc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -635,9 +635,10 @@ private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) { } private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) { - val storage = MessagingModuleConfiguration.shared.storage GlobalScope.launch(Dispatchers.Default) { - storage.handleMemberLeft(message, closedGroup) + runCatching { + MessagingModuleConfiguration.shared.groupManagerV2.handleMemberLeft(message, closedGroup) + } } }