Extract login into GroupManagerV2

This commit is contained in:
SessionHero01 2024-09-18 10:46:49 +10:00
parent 32f95337d5
commit 80e3e563ce
No known key found for this signature in database
16 changed files with 597 additions and 493 deletions

View File

@ -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()");

View File

@ -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
}

View File

@ -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)
}

View File

@ -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<String>) {
// 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<String>()
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<Any>).first() as Map<String,Any>
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<AccountId>) {
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<AccountId>,
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<String>()
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<AccountId>,
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)

View File

@ -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

View File

@ -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<String?>(null)
@ -165,8 +164,12 @@ class EditGroupViewModel @AssistedInject constructor(
}
fun onContactSelected(contacts: Set<Contact>) {
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

View File

@ -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<AccountId>,
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<SnodeAPI.SnodeBatchRequestInfo>()
val messagesToDelete = mutableListOf<String>() // 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<AccountId>,
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<AccountId>) {
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<AccountId>,
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<String>()
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
}
}
}

View File

@ -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<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>()
@ -600,7 +602,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
thread = recipient,
threadID = threadID,
configFactory = configFactory,
storage = storage
storage = storage,
groupManager = groupManagerV2,
)
return

@ -1 +1 @@
Subproject commit 0193c36e0dad461385d6407a00f33b7314e6d740
Subproject commit 995e22dcbf08b3cb9e2ad595859e4cd9a4ed8776

View File

@ -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);
}
}

View File

@ -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<String>)
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<AccountId>)
suspend fun removeMember(groupAccountId: AccountId, removedMembers: List<AccountId>, 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<String>): Promise<Unit, Exception>

View File

@ -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 {

View File

@ -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<AccountId>,
shareHistory: Boolean
)
suspend fun removeMembers(
groupAccountId: AccountId,
removedMembers: List<AccountId>,
removeMessages: Boolean
)
suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId)
suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean)
suspend fun promoteMember(group: AccountId, members: List<AccountId>)
}

View File

@ -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
)

View File

@ -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 {

View File

@ -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)
}
}
}