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.avatars.AvatarHelper;
import org.session.libsession.database.MessageDataProvider; import org.session.libsession.database.MessageDataProvider;
import org.session.libsession.messaging.MessagingModuleConfiguration; 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.notifications.TokenFetcher;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2; import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2;
@ -164,6 +165,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject @Inject
PushRegistrationHandler pushRegistrationHandler; PushRegistrationHandler pushRegistrationHandler;
@Inject TokenFetcher tokenFetcher; @Inject TokenFetcher tokenFetcher;
@Inject GroupManagerV2 groupManagerV2;
CallMessageProcessor callMessageProcessor; CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@ -245,7 +247,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
configFactory, configFactory,
lastSentTimestampCache, lastSentTimestampCache,
this, this,
tokenFetcher tokenFetcher,
groupManagerV2
); );
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()"); 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 nl.komponents.kovenant.ui.successUi
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration 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.ExpirationConfiguration
import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.applyExpiryMode
import org.session.libsession.messaging.messages.control.DataExtractionNotification 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 viewModelFactory: ConversationViewModel.AssistedFactory
@Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory @Inject lateinit var mentionViewModelFactory: MentionViewModel.AssistedFactory
@Inject lateinit var configFactory: ConfigFactory @Inject lateinit var configFactory: ConfigFactory
@Inject lateinit var groupManagerV2: GroupManagerV2
private val screenshotObserver by lazy { private val screenshotObserver by lazy {
ScreenshotObserver(this, Handler(Looper.getMainLooper())) { ScreenshotObserver(this, Handler(Looper.getMainLooper())) {
@ -1218,7 +1220,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
thread = recipient, thread = recipient,
threadID = threadId, threadID = threadId,
factory = configFactory, factory = configFactory,
storage = storage storage = storage,
groupManager = groupManagerV2,
) )
} ?: false } ?: false
} }

View File

@ -18,9 +18,14 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import com.squareup.phrase.Phrase 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 java.io.IOException
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.database.StorageProtocol 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.MessageSender
import org.session.libsession.messaging.sending_receiving.leave import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
@ -154,7 +159,8 @@ object ConversationMenuHelper {
thread: Recipient, thread: Recipient,
threadID: Long, threadID: Long,
factory: ConfigFactory, factory: ConfigFactory,
storage: StorageProtocol storage: StorageProtocol,
groupManager: GroupManagerV2,
): Boolean { ): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.menu_view_all_media -> { showAllMedia(context, thread) } 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_account_id -> { copyAccountID(context, thread) }
R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) }
R.id.menu_edit_group -> { editClosedGroup(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_invite_to_open_group -> { inviteContacts(context, thread) }
R.id.menu_unmute_notifications -> { unmute(context, thread) } R.id.menu_unmute_notifications -> { unmute(context, thread) }
R.id.menu_mute_notifications -> { mute(context, thread) } R.id.menu_mute_notifications -> { mute(context, thread) }
@ -311,7 +317,8 @@ object ConversationMenuHelper {
thread: Recipient, thread: Recipient,
threadID: Long, threadID: Long,
configFactory: ConfigFactory, configFactory: ConfigFactory,
storage: StorageProtocol storage: StorageProtocol,
groupManager: GroupManagerV2,
) { ) {
when { when {
thread.isLegacyClosedGroupRecipient -> { thread.isLegacyClosedGroupRecipient -> {
@ -351,7 +358,7 @@ object ConversationMenuHelper {
threadID = threadID, threadID = threadID,
storage = storage, storage = storage,
doLeave = { doLeave = {
check(storage.leaveGroup(accountId.hexString, true)) groupManager.leaveGroup(accountId, true)
} }
) )
} }
@ -364,7 +371,7 @@ object ConversationMenuHelper {
isAdmin: Boolean, isAdmin: Boolean,
threadID: Long, threadID: Long,
storage: StorageProtocol, storage: StorageProtocol,
doLeave: () -> Unit, doLeave: suspend () -> Unit,
) { ) {
val message = if (isAdmin) { val message = if (isAdmin) {
Phrase.from(context, R.string.groupDeleteDescription) Phrase.from(context, R.string.groupDeleteDescription)
@ -387,15 +394,20 @@ object ConversationMenuHelper {
title(R.string.groupLeave) title(R.string.groupLeave)
text(message) text(message)
dangerButton(R.string.leave) { dangerButton(R.string.leave) {
GlobalScope.launch(Dispatchers.Default) {
try { try {
// Cancel any outstanding jobs // Cancel any outstanding jobs
storage.cancelPendingMessageSendJobs(threadID) storage.cancelPendingMessageSendJobs(threadID)
doLeave() doLeave()
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) {
onLeaveFailed() onLeaveFailed()
} }
} }
}
}
button(R.string.cancel) button(R.string.cancel)
} }
} }

View File

@ -5,7 +5,6 @@ import android.net.Uri
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import network.loki.messenger.libsession_util.Config import network.loki.messenger.libsession_util.Config
import network.loki.messenger.R
import java.security.MessageDigest import java.security.MessageDigest
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
@ -1398,9 +1397,7 @@ open class Storage(
MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString)) MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString))
} else { } else {
// Update our on member state // Update our on member state
configFactory.getGroupMemberConfig(groupSessionId)?.use { members -> configFactory.withGroupConfigsOrNull(groupSessionId) { info, members, keys ->
configFactory.getGroupInfoConfig(groupSessionId)?.use { info ->
configFactory.getGroupKeysConfig(groupSessionId, info)?.use { keys ->
members.get(getUserPublicKey().orEmpty())?.let { member -> members.get(getUserPublicKey().orEmpty())?.let { member ->
members.set(member.setPromoteSuccess().setInvited()) members.set(member.setPromoteSuccess().setInvited())
} }
@ -1408,8 +1405,6 @@ open class Storage(
configFactory.saveGroupConfigs(keys, info, members) configFactory.saveGroupConfigs(keys, info, members)
} }
} }
}
}
configFactory.persist(groups, SnodeAPI.nowWithOffset) configFactory.persist(groups, SnodeAPI.nowWithOffset)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
@ -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? { override fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? {
val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset val sentTimestamp = message.sentTimestamp ?: SnodeAPI.nowWithOffset
val senderPublicKey = message.sender 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) { override fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId) {
insertGroupInfoChange(message, closedGroupId) insertGroupInfoChange(message, closedGroupId)
@ -2004,49 +1614,6 @@ open class Storage(
pollerFactory.pollerFor(groupAccountId)?.stop() 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) { override fun setName(groupSessionId: String, newName: String) {
val closedGroupId = AccountId(groupSessionId) val closedGroupId = AccountId(groupSessionId)

View File

@ -9,9 +9,11 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.Toaster import org.session.libsession.utilities.Toaster
import org.thoughtcrime.securesms.groups.GroupManagerV2Impl
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository import org.thoughtcrime.securesms.repository.DefaultConversationRepository
import javax.inject.Singleton import javax.inject.Singleton
@ -25,6 +27,9 @@ abstract class AppModule {
@Binds @Binds
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
@Binds
abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2
} }
@Module @Module

View File

@ -6,14 +6,11 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn 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 network.loki.messenger.libsession_util.util.GroupMember
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact 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.InviteContactsJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.AccountId
@ -34,7 +32,8 @@ const val MAX_GROUP_NAME_LENGTH = 100
class EditGroupViewModel @AssistedInject constructor( class EditGroupViewModel @AssistedInject constructor(
@Assisted private val groupSessionId: String, @Assisted private val groupSessionId: String,
private val storage: StorageProtocol, private val storage: StorageProtocol,
configFactory: ConfigFactory configFactory: ConfigFactory,
private val groupManager: GroupManagerV2,
) : ViewModel() { ) : ViewModel() {
// Input/Output state // Input/Output state
private val mutableEditingName = MutableStateFlow<String?>(null) private val mutableEditingName = MutableStateFlow<String?>(null)
@ -165,8 +164,12 @@ class EditGroupViewModel @AssistedInject constructor(
} }
fun onContactSelected(contacts: Set<Contact>) { fun onContactSelected(contacts: Set<Contact>) {
viewModelScope.launch(Dispatchers.Default) { performGroupOperation {
storage.inviteClosedGroupMembers(groupSessionId, contacts.map { it.accountID }) groupManager.inviteMembers(
AccountId(hexString = groupSessionId),
contacts.map { AccountId(it.accountID) },
shareHistory = true
)
} }
} }
@ -177,34 +180,19 @@ class EditGroupViewModel @AssistedInject constructor(
} }
fun onPromoteContact(memberSessionId: String) { fun onPromoteContact(memberSessionId: String) {
viewModelScope.launch(Dispatchers.Default) { performGroupOperation {
storage.promoteMember(AccountId(groupSessionId), listOf(AccountId(memberSessionId))) groupManager.promoteMember(AccountId(groupSessionId), listOf(AccountId(memberSessionId)))
} }
} }
fun onRemoveContact(contactSessionId: String, removeMessages: Boolean) { fun onRemoveContact(contactSessionId: String, removeMessages: Boolean) {
viewModelScope.launch { performGroupOperation {
mutableInProgress.value = true groupManager.removeMembers(
// 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), groupAccountId = AccountId(groupSessionId),
removedMembers = listOf(AccountId(contactSessionId)), removedMembers = listOf(AccountId(contactSessionId)),
removeMessages = removeMessages removeMessages = removeMessages
) )
} }
try {
task.join()
} catch (e: Exception) {
mutableError.value = e.localizedMessage.orEmpty()
} finally {
mutableInProgress.value = false
}
}
} }
fun onResendPromotionClicked(memberSessionId: String) { fun onResendPromotionClicked(memberSessionId: String) {
@ -240,6 +228,32 @@ class EditGroupViewModel @AssistedInject constructor(
mutableError.value = null 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 @AssistedFactory
interface Factory { interface Factory {
fun create(groupSessionId: String): EditGroupViewModel 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 org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager import com.bumptech.glide.RequestManager
import org.session.libsession.messaging.groups.GroupManagerV2
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.preferences.SettingsActivity
@ -118,6 +119,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory @Inject lateinit var configFactory: ConfigFactory
@Inject lateinit var groupManagerV2: GroupManagerV2
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>() private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>() private val homeViewModel by viewModels<HomeViewModel>()
@ -600,7 +602,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
thread = recipient, thread = recipient,
threadID = threadID, threadID = threadID,
configFactory = configFactory, configFactory = configFactory,
storage = storage storage = storage,
groupManager = groupManagerV2,
) )
return return

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

View File

@ -101,3 +101,4 @@ Java_network_loki_messenger_libsession_1util_GroupMembersConfig_set(JNIEnv *env,
auto deserialized = util::deserialize_group_member(env, group_member); auto deserialized = util::deserialize_group_member(env, group_member);
config->set(deserialized); config->set(deserialized);
} }

View File

@ -177,16 +177,11 @@ interface StorageProtocol {
fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: AccountId) fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: AccountId)
fun getLibSessionClosedGroup(groupAccountId: String): GroupInfo.ClosedGroupInfo? fun getLibSessionClosedGroup(groupAccountId: String): GroupInfo.ClosedGroupInfo?
fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo? fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo?
fun inviteClosedGroupMembers(groupAccountId: String, invitees: List<String>)
fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long?
fun insertGroupInfoLeaving(closedGroup: AccountId): Long? fun insertGroupInfoLeaving(closedGroup: AccountId): Long?
fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) 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 handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId)
fun handleKicked(groupAccountId: AccountId) fun handleKicked(groupAccountId: AccountId)
fun leaveGroup(groupSessionId: String, deleteOnLeave: Boolean): Boolean
fun setName(groupSessionId: String, newName: String) fun setName(groupSessionId: String, newName: String)
fun sendGroupUpdateDeleteMessage(groupSessionId: String, messageHashes: List<String>): Promise<Unit, Exception> 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 com.goterl.lazysodium.utils.KeyPair
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.notifications.TokenFetcher
import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
@ -20,6 +21,7 @@ class MessagingModuleConfiguration(
val lastSentTimestampCache: LastSentTimestampCache, val lastSentTimestampCache: LastSentTimestampCache,
val toaster: Toaster, val toaster: Toaster,
val tokenFetcher: TokenFetcher, val tokenFetcher: TokenFetcher,
val groupManagerV2: GroupManagerV2,
) { ) {
companion object { 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 sentTimestamp = SnodeAPI.nowWithOffset
val message = val message =
SnodeMessage( SnodeMessage(
auth.accountId.hexString, auth.accountId.hexString,
Base64.encodeBytes(pendingConfig()!!), // should not be null from checking has pending Base64.encodeBytes(pending),
SnodeMessage.CONFIG_TTL, SnodeMessage.CONFIG_TTL,
sentTimestamp sentTimestamp
) )

View File

@ -28,7 +28,11 @@ class LibSessionGroupLeavingJob(val accountId: AccountId, val deleteOnLeave: Boo
// do actual group leave request // do actual group leave request
// on success // 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 // message is already deleted, succeed
delegate?.handleJobSucceeded(this, dispatcherName) delegate?.handleJobSucceeded(this, dispatcherName)
} else { } else {

View File

@ -635,9 +635,10 @@ private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) {
} }
private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) { private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) {
val storage = MessagingModuleConfiguration.shared.storage
GlobalScope.launch(Dispatchers.Default) { GlobalScope.launch(Dispatchers.Default) {
storage.handleMemberLeft(message, closedGroup) runCatching {
MessagingModuleConfiguration.shared.groupManagerV2.handleMemberLeft(message, closedGroup)
}
} }
} }