Improvement

This commit is contained in:
SessionHero01 2024-09-24 16:26:29 +10:00
parent 80e3e563ce
commit 8c1eb1550b
No known key found for this signature in database
17 changed files with 959 additions and 489 deletions

View File

@ -144,7 +144,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private TypingStatusRepository typingStatusRepository; private TypingStatusRepository typingStatusRepository;
private TypingStatusSender typingStatusSender; private TypingStatusSender typingStatusSender;
private ReadReceiptManager readReceiptManager; private ReadReceiptManager readReceiptManager;
private ProfileManager profileManager;
public MessageNotifier messageNotifier = null; public MessageNotifier messageNotifier = null;
public Poller poller = null; public Poller poller = null;
public Broadcaster broadcaster = null; public Broadcaster broadcaster = null;
@ -166,6 +166,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
PushRegistrationHandler pushRegistrationHandler; PushRegistrationHandler pushRegistrationHandler;
@Inject TokenFetcher tokenFetcher; @Inject TokenFetcher tokenFetcher;
@Inject GroupManagerV2 groupManagerV2; @Inject GroupManagerV2 groupManagerV2;
@Inject SSKEnvironment.ProfileManagerProtocol profileManager;
CallMessageProcessor callMessageProcessor; CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@ -268,9 +269,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
initializeTypingStatusRepository(); initializeTypingStatusRepository();
initializeTypingStatusSender(); initializeTypingStatusSender();
initializeReadReceiptManager(); initializeReadReceiptManager();
initializeProfileManager();
initializePeriodicTasks(); initializePeriodicTasks();
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager()); SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), profileManager, messageNotifier, getExpiringMessageManager());
initializeWebRtc(); initializeWebRtc();
initializeBlobProvider(); initializeBlobProvider();
resubmitProfilePictureIfNeeded(); resubmitProfilePictureIfNeeded();
@ -368,9 +368,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return readReceiptManager; return readReceiptManager;
} }
public ProfileManager getProfileManager() {
return profileManager;
}
public boolean isAppVisible() { public boolean isAppVisible() {
return isAppVisible; return isAppVisible;
@ -426,10 +423,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
this.readReceiptManager = new ReadReceiptManager(); this.readReceiptManager = new ReadReceiptManager();
} }
private void initializeProfileManager() {
this.profileManager = new ProfileManager(this, configFactory);
}
private void initializeTypingStatusSender() { private void initializeTypingStatusSender() {
this.typingStatusSender = new TypingStatusSender(this); this.typingStatusSender = new TypingStatusSender(this);
} }

View File

@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.GroupMember import network.loki.messenger.libsession_util.util.GroupMember
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
@ -36,7 +35,6 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
@ -47,7 +45,6 @@ class ConversationViewModel(
private val messageDataProvider: MessageDataProvider, private val messageDataProvider: MessageDataProvider,
private val groupDb: GroupDatabase, private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase, private val threadDb: ThreadDatabase,
private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
@ -348,10 +345,6 @@ class ConversationViewModel(
_uiState.update { _uiState.update {
it.copy(messageRequestState = MessageRequestUiState.Invisible) it.copy(messageRequestState = MessageRequestUiState.Invisible)
} }
withContext(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
}
} }
.onFailure { .onFailure {
showMessage("Couldn't accept message request due to error: $it") showMessage("Couldn't accept message request due to error: $it")
@ -362,10 +355,14 @@ class ConversationViewModel(
} }
} }
fun declineMessageRequest() { fun declineMessageRequest() = viewModelScope.launch {
repository.declineMessageRequest(threadId, recipient!!) repository.declineMessageRequest(threadId, recipient!!)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext) .onSuccess {
_uiState.update { it.copy(shouldExit = true) } _uiState.update { it.copy(shouldExit = true) }
}
.onFailure {
showMessage("Couldn't decline message request due to error: $it")
}
} }
private fun showMessage(message: String) { private fun showMessage(message: String) {
@ -456,7 +453,6 @@ class ConversationViewModel(
messageDataProvider = messageDataProvider, messageDataProvider = messageDataProvider,
groupDb = groupDb, groupDb = groupDb,
threadDb = threadDb, threadDb = threadDb,
appContext = context,
) as T ) as T
} }
} }

View File

@ -1079,137 +1079,6 @@ open class Storage(
DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
} }
override fun createNewGroup(groupName: String, groupDescription: String, members: Set<Contact>): Optional<Recipient> {
val userGroups = configFactory.userGroups ?: return Optional.absent()
val convoVolatile = configFactory.convoVolatile ?: return Optional.absent()
val ourSessionId = getUserPublicKey() ?: return Optional.absent()
val groupCreationTimestamp = SnodeAPI.nowWithOffset
val group = userGroups.createGroup()
val adminKey = checkNotNull(group.adminKey) {
"Admin key is null for new group creation."
}
userGroups.set(group)
val groupInfo = configFactory.getGroupInfoConfig(group.groupAccountId) ?: return Optional.absent()
val groupMembers = configFactory.getGroupMemberConfig(group.groupAccountId) ?: return Optional.absent()
with (groupInfo) {
setName(groupName)
setDescription(groupDescription)
}
groupMembers.set(
LibSessionGroupMember(ourSessionId, getUserProfile().displayName, admin = true)
)
members.forEach { groupMembers.set(LibSessionGroupMember(it.accountID, it.name).setInvited()) }
val groupKeys = configFactory.constructGroupKeysConfig(group.groupAccountId,
info = groupInfo,
members = groupMembers) ?: return Optional.absent()
// Manually re-key to prevent issue with linked admin devices
groupKeys.rekey(groupInfo, groupMembers)
val newGroupRecipient = group.groupAccountId.hexString
val configTtl = 14 * 24 * 60 * 60 * 1000L
// Test the sending
val keyPush = groupKeys.pendingConfig() ?: return Optional.absent()
val groupAdminSigner = OwnedSwarmAuth.ofClosedGroup(group.groupAccountId, adminKey)
val keysSnodeMessage = SnodeMessage(
newGroupRecipient,
Base64.encodeBytes(keyPush),
configTtl,
groupCreationTimestamp
)
val keysBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
groupKeys.namespace(),
keysSnodeMessage,
groupAdminSigner
)
val (infoPush, infoSeqNo) = groupInfo.push()
val infoSnodeMessage = SnodeMessage(
newGroupRecipient,
Base64.encodeBytes(infoPush),
configTtl,
groupCreationTimestamp
)
val infoBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
groupInfo.namespace(),
infoSnodeMessage,
groupAdminSigner
)
val (memberPush, memberSeqNo) = groupMembers.push()
val memberSnodeMessage = SnodeMessage(
newGroupRecipient,
Base64.encodeBytes(memberPush),
configTtl,
groupCreationTimestamp
)
val memberBatchInfo = SnodeAPI.buildAuthenticatedStoreBatchInfo(
groupMembers.namespace(),
memberSnodeMessage,
groupAdminSigner
)
try {
val snode = SnodeAPI.getSingleTargetSnode(newGroupRecipient).get()
val response = SnodeAPI.getRawBatchResponse(
snode,
newGroupRecipient,
listOf(keysBatchInfo, infoBatchInfo, memberBatchInfo),
true
).get()
@Suppress("UNCHECKED_CAST")
val responseList = (response["results"] as List<RawResponse>)
val keyResponse = responseList[0]
val keyHash = (keyResponse["body"] as Map<String,Any>)["hash"] as String
val keyTimestamp = (keyResponse["body"] as Map<String,Any>)["t"] as Long
val infoResponse = responseList[1]
val infoHash = (infoResponse["body"] as Map<String,Any>)["hash"] as String
val memberResponse = responseList[2]
val memberHash = (memberResponse["body"] as Map<String,Any>)["hash"] as String
// TODO: check response success
groupKeys.loadKey(keyPush, keyHash, keyTimestamp, groupInfo, groupMembers)
groupInfo.confirmPushed(infoSeqNo, infoHash)
groupMembers.confirmPushed(memberSeqNo, memberHash)
configFactory.saveGroupConfigs(groupKeys, groupInfo, groupMembers) // now check poller to be all
convoVolatile.set(Conversation.ClosedGroup(newGroupRecipient, groupCreationTimestamp, false))
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
val groupRecipient = Recipient.from(context, fromSerialized(newGroupRecipient), false)
SSKEnvironment.shared.profileManager.setName(context, groupRecipient, groupInfo.getName())
setRecipientApprovedMe(groupRecipient, true)
setRecipientApproved(groupRecipient, true)
Log.d("Group Config", "Saved group config for $newGroupRecipient")
pollerFactory.updatePollers()
val memberArray = members.map(Contact::accountID).toTypedArray()
val job = InviteContactsJob(group.groupAccountId.hexString, memberArray)
JobQueue.shared.add(job)
return Optional.of(groupRecipient)
} catch (e: Exception) {
Log.e("Group Config", e)
Log.e("Group Config", "Deleting group from our group")
// delete the group from user groups
userGroups.erase(group)
} finally {
groupKeys.free()
groupInfo.free()
groupMembers.free()
}
return Optional.absent()
}
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) { override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) {
val volatiles = configFactory.convoVolatile ?: return val volatiles = configFactory.convoVolatile ?: return
val userGroups = configFactory.userGroups ?: return val userGroups = configFactory.userGroups ?: return
@ -1375,141 +1244,6 @@ open class Storage(
override fun getMembers(groupPublicKey: String): List<LibSessionGroupMember> = override fun getMembers(groupPublicKey: String): List<LibSessionGroupMember> =
configFactory.getGroupMemberConfig(AccountId(groupPublicKey))?.use { it.all() }?.toList() ?: emptyList() configFactory.getGroupMemberConfig(AccountId(groupPublicKey))?.use { it.all() }?.toList() ?: emptyList()
private fun approveGroupInvite(threadId: Long, groupSessionId: AccountId) {
val groups = configFactory.userGroups ?: return
val group = groups.getClosedGroup(groupSessionId.hexString) ?: return
configFactory.persist(
forConfigObject = groups.apply { set(group.copy(invited = false)) },
timestamp = SnodeAPI.nowWithOffset
)
// Send invite response if we aren't admin. If we already have admin access,
// the group configs are already up-to-date (hence no need to reponse to the invite)
if (group.adminKey == null) {
val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder()
.setIsApproved(true)
val responseData = GroupUpdateMessage.newBuilder()
.setInviteResponse(inviteResponse)
val responseMessage = GroupUpdated(responseData.build())
clearMessages(threadId)
// this will fail the first couple of times :)
MessageSender.send(responseMessage, fromSerialized(groupSessionId.hexString))
} else {
// Update our on member state
configFactory.withGroupConfigsOrNull(groupSessionId) { info, members, keys ->
members.get(getUserPublicKey().orEmpty())?.let { member ->
members.set(member.setPromoteSuccess().setInvited())
}
configFactory.saveGroupConfigs(keys, info, members)
}
}
configFactory.persist(groups, SnodeAPI.nowWithOffset)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
pollerFactory.pollerFor(groupSessionId)?.start()
// clear any group invites for this session ID (just in case there's a re-invite from an approved member after an invite from non-approved)
DatabaseComponent.get(context).lokiMessageDatabase().deleteGroupInviteReferrer(threadId)
}
override fun respondToClosedGroupInvitation(
threadId: Long,
groupRecipient: Recipient,
approved: Boolean
) {
val groups = configFactory.userGroups ?: return
val groupSessionId = AccountId(groupRecipient.address.serialize())
// Whether approved or not, delete the invite
DatabaseComponent.get(context).lokiMessageDatabase().deleteGroupInviteReferrer(threadId)
if (!approved) {
groups.eraseClosedGroup(groupSessionId.hexString)
configFactory.persist(groups, SnodeAPI.nowWithOffset)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
deleteConversation(threadId)
return
} else {
approveGroupInvite(threadId, groupSessionId)
}
}
override fun addClosedGroupInvite(
groupId: AccountId,
name: String,
authData: ByteArray?,
adminKey: ByteArray?,
invitingAdmin: AccountId,
invitingMessageHash: String?,
) {
require(authData != null || adminKey != null) {
"Must provide either authData or adminKey"
}
val recipient = Recipient.from(context, fromSerialized(groupId.hexString), false)
val profileManager = SSKEnvironment.shared.profileManager
val groups = configFactory.userGroups ?: return
val inviteDb = DatabaseComponent.get(context).lokiMessageDatabase()
val shouldAutoApprove = getRecipientApproved(fromSerialized(invitingAdmin.hexString))
val closedGroupInfo = GroupInfo.ClosedGroupInfo(
groupAccountId = groupId,
adminKey = adminKey,
authData = authData,
priority = PRIORITY_VISIBLE,
invited = !shouldAutoApprove,
name = name,
)
groups.set(closedGroupInfo)
configFactory.persist(groups, SnodeAPI.nowWithOffset)
profileManager.setName(context, recipient, name)
val groupThreadId = getOrCreateThreadIdFor(recipient.address)
setRecipientApprovedMe(recipient, true)
setRecipientApproved(recipient, shouldAutoApprove)
if (shouldAutoApprove) {
approveGroupInvite(groupThreadId, groupId)
} else {
inviteDb.addGroupInviteReferrer(groupThreadId, invitingAdmin.hexString)
insertGroupInviteControlMessage(SnodeAPI.nowWithOffset, invitingAdmin.hexString, groupId, name)
}
val userAuth = this.userAuth
if (invitingMessageHash != null && userAuth != null) {
val batch = SnodeAPI.buildAuthenticatedDeleteBatchInfo(
auth = userAuth,
listOf(invitingMessageHash)
)
SnodeAPI.getSingleTargetSnode(userAuth.accountId.hexString).map { snode ->
SnodeAPI.getRawBatchResponse(snode, userAuth.accountId.hexString, listOf(batch))
}.success {
Log.d(TAG, "Successfully deleted invite message")
}.fail { e ->
Log.e(TAG, "Error deleting invite message", e)
}
}
}
override fun setGroupInviteCompleteIfNeeded(approved: Boolean, invitee: String, closedGroup: AccountId) {
// don't try to process invitee acceptance if we aren't admin
if (configFactory.userGroups?.getClosedGroup(closedGroup.hexString)?.hasAdminKey() != true) return
configFactory.getGroupMemberConfig(closedGroup)?.use { groupMembers ->
val member = groupMembers.get(invitee) ?: run {
Log.e("ClosedGroup", "User wasn't in the group membership to add!")
return
}
if (!member.invitePending) return groupMembers.close()
if (approved) {
groupMembers.set(member.setAccepted())
} else {
groupMembers.erase(member)
}
configFactory.persistGroupConfigDump(groupMembers, closedGroup, SnodeAPI.nowWithOffset)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(Destination.ClosedGroup(closedGroup.hexString))
}
}
override fun getLibSessionClosedGroup(groupSessionId: String): GroupInfo.ClosedGroupInfo? { override fun getLibSessionClosedGroup(groupSessionId: String): GroupInfo.ClosedGroupInfo? {
return configFactory.userGroups?.getClosedGroup(groupSessionId) return configFactory.userGroups?.getClosedGroup(groupSessionId)
@ -1557,7 +1291,7 @@ open class Storage(
mmsDB.updateInfoMessage(messageId, newMessage.toJSON()) mmsDB.updateInfoMessage(messageId, newMessage.toJSON())
} }
private fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, closedGroup: AccountId, groupName: String): Long? { override fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, closedGroup: AccountId, groupName: String): Long? {
val updateData = UpdateMessageData(UpdateMessageData.Kind.GroupInvitation(senderPublicKey, groupName)) val updateData = UpdateMessageData(UpdateMessageData.Kind.GroupInvitation(senderPublicKey, groupName))
return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup) return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
} }
@ -1610,46 +1344,6 @@ open class Storage(
insertGroupInfoChange(message, closedGroupId) insertGroupInfoChange(message, closedGroupId)
} }
override fun handleKicked(groupAccountId: AccountId) {
pollerFactory.pollerFor(groupAccountId)?.stop()
}
override fun setName(groupSessionId: String, newName: String) {
val closedGroupId = AccountId(groupSessionId)
val adminKey = configFactory.userGroups?.getClosedGroup(groupSessionId)?.adminKey ?: return
if (adminKey.isEmpty()) {
return Log.e("ClosedGroup", "No admin key for group")
}
configFactory.withGroupConfigsOrNull(closedGroupId) { info, members, keys ->
info.setName(newName)
configFactory.saveGroupConfigs(keys, info, members)
}
val groupDestination = Destination.ClosedGroup(groupSessionId)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
val timestamp = SnodeAPI.nowWithOffset
val signature = SodiumUtilities.sign(
buildInfoChangeVerifier(GroupUpdateInfoChangeMessage.Type.NAME, timestamp),
adminKey
)
val message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setInfoChangeMessage(
GroupUpdateInfoChangeMessage.newBuilder()
.setUpdatedName(newName)
.setType(GroupUpdateInfoChangeMessage.Type.NAME)
.setAdminSignature(ByteString.copyFrom(signature))
)
.build()
).apply {
sentTimestamp = timestamp
}
MessageSender.send(message, fromSerialized(groupSessionId))
insertGroupInfoChange(message, closedGroupId)
}
override fun sendGroupUpdateDeleteMessage(groupSessionId: String, messageHashes: List<String>): Promise<Unit, Exception> { override fun sendGroupUpdateDeleteMessage(groupSessionId: String, messageHashes: List<String>): Promise<Unit, Exception> {
val closedGroup = configFactory.userGroups?.getClosedGroup(groupSessionId) val closedGroup = configFactory.userGroups?.getClosedGroup(groupSessionId)

View File

@ -11,11 +11,13 @@ 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.messaging.groups.GroupManagerV2
import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.SSKEnvironment
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.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 org.thoughtcrime.securesms.sskenvironment.ProfileManager
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -30,6 +32,9 @@ abstract class AppModule {
@Binds @Binds
abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2 abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2
@Binds
abstract fun bindProfileManager(profileManager: ProfileManager): SSKEnvironment.ProfileManagerProtocol
} }
@Module @Module

View File

@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject import javax.inject.Inject
@ -19,6 +20,7 @@ import javax.inject.Inject
class CreateGroupViewModel @Inject constructor( class CreateGroupViewModel @Inject constructor(
configFactory: ConfigFactory, configFactory: ConfigFactory,
private val storage: StorageProtocol, private val storage: StorageProtocol,
private val groupManagerV2: GroupManagerV2,
): ViewModel() { ): ViewModel() {
// Child view model to handle contact selection logic // Child view model to handle contact selection logic
val selectContactsViewModel = SelectContactsViewModel( val selectContactsViewModel = SelectContactsViewModel(
@ -60,15 +62,25 @@ class CreateGroupViewModel @Inject constructor(
mutableIsLoading.value = true mutableIsLoading.value = true
val recipient = withContext(Dispatchers.Default) { val createResult = withContext(Dispatchers.Default) {
storage.createNewGroup(groupName, "", selected) runCatching {
groupManagerV2.createGroup(
groupName = groupName,
groupDescription = "",
members = selected
)
}
} }
if (recipient.isPresent) { when (val recipient = createResult.getOrNull()) {
val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.get().address) } null -> {
mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId)) mutableEvents.emit(CreateGroupEvent.Error("Failed to create group"))
} else {
mutableEvents.emit(CreateGroupEvent.Error("Failed to create group")) }
else -> {
val threadId = withContext(Dispatchers.Default) { storage.getOrCreateThreadIdFor(recipient.address) }
mutableEvents.emit(CreateGroupEvent.NavigateToConversation(threadId))
}
} }
mutableIsLoading.value = false mutableIsLoading.value = false

View File

@ -3,10 +3,25 @@ package org.thoughtcrime.securesms.groups
import android.content.Context import android.content.Context
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE
import network.loki.messenger.libsession_util.GroupInfoConfig
import network.loki.messenger.libsession_util.GroupKeysConfig
import network.loki.messenger.libsession_util.GroupMembersConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.GroupMember
import network.loki.messenger.libsession_util.util.INVITE_STATUS_SENT 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.Sodium
import network.loki.messenger.libsession_util.util.UserPic import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.database.userAuth
import org.session.libsession.messaging.MessagingModuleConfiguration
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.groups.GroupManagerV2
import org.session.libsession.messaging.jobs.ConfigurationSyncJob.Companion.messageInformation import org.session.libsession.messaging.jobs.ConfigurationSyncJob.Companion.messageInformation
@ -14,23 +29,32 @@ import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.messaging.messages.visible.Profile
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature
import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeVerifier
import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.OwnedSwarmAuth
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SnodeMessage
import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.BatchResponse
import org.session.libsession.snode.model.StoreMessageResponse
import org.session.libsession.snode.utilities.await import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.withGroupConfigsOrNull import org.session.libsession.utilities.withGroupConfigsOrNull
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos.DataMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInfoChangeMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteResponseMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Namespace
import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
@ -40,15 +64,20 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "GroupManagerV2Impl"
@Singleton @Singleton
class GroupManagerV2Impl @Inject constructor( class GroupManagerV2Impl @Inject constructor(
val storage: StorageProtocol, private val storage: StorageProtocol,
val configFactory: ConfigFactory, private val configFactory: ConfigFactory,
val mmsSmsDatabase: MmsSmsDatabase, private val mmsSmsDatabase: MmsSmsDatabase,
val lokiDatabase: LokiMessageDatabase, private val lokiDatabase: LokiMessageDatabase,
val pollerFactory: PollerFactory, private val pollerFactory: PollerFactory,
private val profileManager: SSKEnvironment.ProfileManagerProtocol,
@ApplicationContext val application: Context, @ApplicationContext val application: Context,
) : GroupManagerV2 { ) : GroupManagerV2 {
private val dispatcher = Dispatchers.Default
/** /**
* Require admin access to a group, and return the admin key. * Require admin access to a group, and return the admin key.
* *
@ -62,11 +91,214 @@ class GroupManagerV2Impl @Inject constructor(
?.takeIf { it.isNotEmpty() }) { "Only admin is allowed to invite members" } ?.takeIf { it.isNotEmpty() }) { "Only admin is allowed to invite members" }
} }
override suspend fun createGroup(
groupName: String,
groupDescription: String,
members: Set<Contact>
): Recipient = withContext(dispatcher) {
val userGroupsConfig =
requireNotNull(configFactory.userGroups) { "User groups config is not available" }
val convoVolatileConfig =
requireNotNull(configFactory.convoVolatile) { "Conversation volatile config is not available" }
val ourAccountId =
requireNotNull(storage.getUserPublicKey()) { "Our account ID is not available" }
val ourKeys =
requireNotNull(storage.getUserED25519KeyPair()) { "Our ED25519 key pair is not available" }
val ourProfile = storage.getUserProfile()
val groupCreationTimestamp = SnodeAPI.nowWithOffset
// Create a group in the user groups config
val group = userGroupsConfig.createGroup()
val adminKey = checkNotNull(group.adminKey) { "Admin key is null for new group creation." }
userGroupsConfig.set(group)
val groupId = group.groupAccountId
val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey)
try {
withNewGroupConfigs(
groupId = groupId,
userSecretKey = ourKeys.secretKey.asBytes,
groupAdminKey = adminKey
) { infoConfig, membersConfig, keysConfig ->
// Update group's information
infoConfig.setName(groupName)
infoConfig.setDescription(groupDescription)
// Add members
for (member in members) {
membersConfig.set(
GroupMember(
sessionId = member.accountID,
name = member.name,
profilePicture = member.profilePicture ?: UserPic.DEFAULT,
inviteStatus = INVITE_STATUS_SENT
)
)
}
// Add ourselves as admin
membersConfig.set(
GroupMember(
sessionId = ourAccountId,
name = ourProfile.displayName,
profilePicture = ourProfile.profilePicture ?: UserPic.DEFAULT,
admin = true
)
)
// Manually re-key to prevent issue with linked admin devices
keysConfig.rekey(infoConfig, membersConfig)
val configTtl = 14 * 24 * 60 * 60 * 1000L // 14 days
// Push keys
val pendingKey = requireNotNull(keysConfig.pendingConfig()) {
"Expect pending keys data to push but got none"
}
val pushKeys = async {
SnodeAPI.sendBatchRequest(
groupId,
SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = keysConfig.namespace(),
message = SnodeMessage(
recipient = groupId.hexString,
data = Base64.encodeBytes(pendingKey),
ttl = configTtl,
timestamp = groupCreationTimestamp
),
auth = groupAuth
),
StoreMessageResponse::class.java
)
}
// Push info
val pushInfo = async {
val (infoPush, infoSeqNo) = infoConfig.push()
infoSeqNo to SnodeAPI.sendBatchRequest(
groupId,
SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = infoConfig.namespace(),
message = SnodeMessage(
recipient = groupId.hexString,
data = Base64.encodeBytes(infoPush),
ttl = configTtl,
timestamp = groupCreationTimestamp
),
auth = groupAuth
),
StoreMessageResponse::class.java
)
}
// Members push
val pushMembers = async {
val (membersPush, membersSeqNo) = membersConfig.push()
membersSeqNo to SnodeAPI.sendBatchRequest(
groupId,
SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = membersConfig.namespace(),
message = SnodeMessage(
recipient = groupId.hexString,
data = Base64.encodeBytes(membersPush),
ttl = configTtl,
timestamp = groupCreationTimestamp
),
auth = groupAuth
),
StoreMessageResponse::class.java
)
}
// Wait for all the push requests to finish then update the configs
val (keyHash, keyTimestamp) = pushKeys.await()
val (infoSeqNo, infoHash) = pushInfo.await()
val (membersSeqNo, membersHash) = pushMembers.await()
keysConfig.loadKey(pendingKey, keyHash, keyTimestamp, infoConfig, membersConfig)
infoConfig.confirmPushed(infoSeqNo, infoHash.hash)
membersConfig.confirmPushed(membersSeqNo, membersHash.hash)
configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig)
// Add a new conversation into the volatile convo config and sync
convoVolatileConfig.set(
Conversation.ClosedGroup(
groupId.hexString,
groupCreationTimestamp,
false
)
)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application)
val recipient =
Recipient.from(application, Address.fromSerialized(groupId.hexString), false)
// Apply various data locally
profileManager.setName(application, recipient, groupName)
storage.setRecipientApprovedMe(recipient, true)
storage.setRecipientApproved(recipient, true)
pollerFactory.updatePollers()
// Invite members
JobQueue.shared.add(
InviteContactsJob(
groupSessionId = groupId.hexString,
memberSessionIds = members.map { it.accountID }.toTypedArray()
)
)
recipient
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create group", e)
// Remove the group from the user groups config is sufficient as a "rollback"
userGroupsConfig.erase(group)
throw e
}
}
private suspend fun <T> withNewGroupConfigs(
groupId: AccountId,
userSecretKey: ByteArray,
groupAdminKey: ByteArray,
block: suspend CoroutineScope.(GroupInfoConfig, GroupMembersConfig, GroupKeysConfig) -> T
): T {
return GroupInfoConfig.newInstance(
pubKey = groupId.pubKeyBytes,
secretKey = groupAdminKey
).use { infoConfig ->
GroupMembersConfig.newInstance(
pubKey = groupId.pubKeyBytes,
secretKey = groupAdminKey
).use { membersConfig ->
GroupKeysConfig.newInstance(
userSecretKey = userSecretKey,
groupPublicKey = groupId.pubKeyBytes,
groupSecretKey = groupAdminKey,
info = infoConfig,
members = membersConfig
).use { keysConfig ->
coroutineScope {
this.block(infoConfig, membersConfig, keysConfig)
}
}
}
}
}
override suspend fun inviteMembers( override suspend fun inviteMembers(
group: AccountId, group: AccountId,
newMembers: List<AccountId>, newMembers: List<AccountId>,
shareHistory: Boolean shareHistory: Boolean
) { ): Unit = withContext(dispatcher) {
val adminKey = requireAdminAccess(group) val adminKey = requireAdminAccess(group)
val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey)
@ -76,7 +308,10 @@ class GroupManagerV2Impl @Inject constructor(
val toSet = membersConfig.get(newMember.hexString) val toSet = membersConfig.get(newMember.hexString)
?.let { existing -> ?.let { existing ->
if (existing.inviteFailed || existing.invitePending) { if (existing.inviteFailed || existing.invitePending) {
existing.copy(inviteStatus = INVITE_STATUS_SENT, supplement = shareHistory) existing.copy(
inviteStatus = INVITE_STATUS_SENT,
supplement = shareHistory
)
} else { } else {
existing existing
} }
@ -105,16 +340,18 @@ class GroupManagerV2Impl @Inject constructor(
if (shareHistory) { if (shareHistory) {
for (member in newMembers) { for (member in newMembers) {
val memberKey = keysConfig.supplementFor(member.hexString) val memberKey = keysConfig.supplementFor(member.hexString)
batchRequests.add(SnodeAPI.buildAuthenticatedStoreBatchInfo( batchRequests.add(
namespace = keysConfig.namespace(), SnodeAPI.buildAuthenticatedStoreBatchInfo(
message = SnodeMessage( namespace = keysConfig.namespace(),
recipient = group.hexString, message = SnodeMessage(
data = Base64.encodeBytes(memberKey), recipient = group.hexString,
ttl = SnodeMessage.CONFIG_TTL, data = Base64.encodeBytes(memberKey),
timestamp = timestamp ttl = SnodeMessage.CONFIG_TTL,
), timestamp = timestamp
auth = groupAuth, ),
)) auth = groupAuth,
)
)
} }
} else { } else {
keysConfig.rekey(infoConfig, membersConfig) keysConfig.rekey(infoConfig, membersConfig)
@ -150,7 +387,12 @@ class GroupManagerV2Impl @Inject constructor(
configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig) configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig)
// Send the invitation message to the new members // Send the invitation message to the new members
JobQueue.shared.add(InviteContactsJob(group.hexString, newMembers.map { it.hexString }.toTypedArray())) JobQueue.shared.add(
InviteContactsJob(
group.hexString,
newMembers.map { it.hexString }.toTypedArray()
)
)
// Send a member change message to the group // Send a member change message to the group
val signature = SodiumUtilities.sign( val signature = SodiumUtilities.sign(
@ -216,7 +458,8 @@ class GroupManagerV2Impl @Inject constructor(
} }
override suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) { override suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) {
val canSendGroupMessage = configFactory.userGroups?.getClosedGroup(group.hexString)?.kicked != true val canSendGroupMessage =
configFactory.userGroups?.getClosedGroup(group.hexString)?.kicked != true
val address = Address.fromSerialized(group.hexString) val address = Address.fromSerialized(group.hexString)
if (canSendGroupMessage) { if (canSendGroupMessage) {
@ -250,30 +493,50 @@ class GroupManagerV2Impl @Inject constructor(
} }
} }
override suspend fun promoteMember(group: AccountId, members: List<AccountId>) { override suspend fun promoteMember(group: AccountId, members: List<AccountId>): Unit = withContext(dispatcher) {
val adminKey = requireAdminAccess(group) val adminKey = requireAdminAccess(group)
configFactory.withGroupConfigsOrNull(group) { info, membersConfig, keys -> configFactory.withGroupConfigsOrNull(group) { info, membersConfig, keys ->
for (member in members) { // Promote the members by sending a message containing the admin key to each member's swarm,
val promoted = membersConfig.get(member.hexString)?.setPromoteSent() ?: continue // we do this concurrently and then update the group configs after all the messages are sent.
membersConfig.set(promoted) val promoteResult = members.asSequence()
.mapNotNull { membersConfig.get(it.hexString) }
val message = GroupUpdated( .map { memberConfig ->
GroupUpdateMessage.newBuilder() async {
.setPromoteMessage( val message = GroupUpdated(
DataMessage.GroupUpdatePromoteMessage.newBuilder() GroupUpdateMessage.newBuilder()
.setGroupIdentitySeed(ByteString.copyFrom(adminKey)) .setPromoteMessage(
.setName(info.getName()) DataMessage.GroupUpdatePromoteMessage.newBuilder()
.setGroupIdentitySeed(ByteString.copyFrom(adminKey))
.setName(info.getName())
)
.build()
) )
.build()
) try {
MessageSender.send(message, Address.fromSerialized(group.hexString)) MessageSender.sendNonDurably(
message = message,
address = Address.fromSerialized(memberConfig.sessionId),
isSyncMessage = false
).await()
memberConfig.setPromoteSent()
} catch (ec: Exception) {
Log.e(TAG, "Failed to send promote message", ec)
memberConfig.setPromoteFailed()
}
}
}
.toList()
for (result in promoteResult) {
membersConfig.set(result.await())
} }
configFactory.saveGroupConfigs(keys, info, membersConfig) configFactory.saveGroupConfigs(keys, info, membersConfig)
} }
// Send a group update message to the group telling members someone has been promoted
val groupDestination = Destination.ClosedGroup(group.hexString) val groupDestination = Destination.ClosedGroup(group.hexString)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
val timestamp = SnodeAPI.nowWithOffset val timestamp = SnodeAPI.nowWithOffset
@ -294,14 +557,16 @@ class GroupManagerV2Impl @Inject constructor(
sentTimestamp = timestamp sentTimestamp = timestamp
} }
MessageSender.send(message, Address.fromSerialized(groupDestination.publicKey)) MessageSender.send(message, Address.fromSerialized(group.hexString))
storage.insertGroupInfoChange(message, group) storage.insertGroupInfoChange(message, group)
} }
private suspend fun doRemoveMembers(group: AccountId, private suspend fun doRemoveMembers(
removedMembers: List<AccountId>, group: AccountId,
sendRemovedMessage: Boolean, removedMembers: List<AccountId>,
removeMemberMessages: Boolean) { sendRemovedMessage: Boolean,
removeMemberMessages: Boolean
) = withContext(dispatcher) {
val adminKey = requireAdminAccess(group) val adminKey = requireAdminAccess(group)
val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey)
@ -324,7 +589,8 @@ class GroupManagerV2Impl @Inject constructor(
) )
this += Sodium.encryptForMultipleSimple( this += Sodium.encryptForMultipleSimple(
messages = removedMembers.map{"${it.hexString}-${keys.currentGeneration()}".encodeToByteArray()}.toTypedArray(), messages = removedMembers.map { "${it.hexString}-${keys.currentGeneration()}".encodeToByteArray() }
.toTypedArray(),
recipients = removedMembers.map { it.pubKeyBytes }.toTypedArray(), recipients = removedMembers.map { it.pubKeyBytes }.toTypedArray(),
ed25519SecretKey = adminKey, ed25519SecretKey = adminKey,
domain = Sodium.KICKED_DOMAIN domain = Sodium.KICKED_DOMAIN
@ -345,22 +611,24 @@ class GroupManagerV2Impl @Inject constructor(
val adminSignature = val adminSignature =
SodiumUtilities.sign( SodiumUtilities.sign(
buildDeleteMemberContentSignature( buildDeleteMemberContentSignature(
memberIds = removedMembers, memberIds = removedMembers,
messageHashes = emptyList(), messageHashes = emptyList(),
timestamp = messageSendTimestamp timestamp = messageSendTimestamp
), adminKey) ), adminKey
)
this += SnodeAPI.buildAuthenticatedStoreBatchInfo( this += SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.CLOSED_GROUP_MESSAGES(), namespace = Namespace.CLOSED_GROUP_MESSAGES(),
message = MessageSender.buildWrappedMessageToSnode( message = MessageSender.buildWrappedMessageToSnode(
destination = Destination.ClosedGroup(group.hexString), destination = Destination.ClosedGroup(group.hexString),
message = GroupUpdated(GroupUpdateMessage.newBuilder() message = GroupUpdated(
.setDeleteMemberContent( GroupUpdateMessage.newBuilder()
GroupUpdateDeleteMemberContentMessage.newBuilder() .setDeleteMemberContent(
.addAllMemberSessionIds(removedMembers.map { it.hexString }) GroupUpdateDeleteMemberContentMessage.newBuilder()
.setAdminSignature(ByteString.copyFrom(adminSignature)) .addAllMemberSessionIds(removedMembers.map { it.hexString })
) .setAdminSignature(ByteString.copyFrom(adminSignature))
.build() )
.build()
).apply { sentTimestamp = messageSendTimestamp }, ).apply { sentTimestamp = messageSendTimestamp },
isSyncMessage = false isSyncMessage = false
), ),
@ -370,7 +638,12 @@ class GroupManagerV2Impl @Inject constructor(
} }
val snode = SnodeAPI.getSingleTargetSnode(group.hexString).await() val snode = SnodeAPI.getSingleTargetSnode(group.hexString).await()
val responses = SnodeAPI.getBatchResponse(snode, group.hexString, essentialRequests, sequence = true) val responses = SnodeAPI.getBatchResponse(
snode,
group.hexString,
essentialRequests,
sequence = true
)
responses.requireAllRequestsSuccessful("Failed to execute essential steps for removing member") responses.requireAllRequestsSuccessful("Failed to execute essential steps for removing member")
@ -403,9 +676,20 @@ class GroupManagerV2Impl @Inject constructor(
this += "Sync keys config messages" to it.batch this += "Sync keys config messages" to it.batch
} }
this += "Sync info config messages" to info.messageInformation(messagesToDelete, groupAuth).batch this += "Sync info config messages" to info.messageInformation(
this += "Sync member config messages" to members.messageInformation(messagesToDelete, groupAuth).batch messagesToDelete,
this += "Delete outdated config and member messages" to SnodeAPI.buildAuthenticatedDeleteBatchInfo(groupAuth, 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( val response = SnodeAPI.getBatchResponse(
@ -422,7 +706,10 @@ class GroupManagerV2Impl @Inject constructor(
if (sendRemovedMessage) { if (sendRemovedMessage) {
val timestamp = messageSendTimestamp val timestamp = messageSendTimestamp
val signature = SodiumUtilities.sign( val signature = SodiumUtilities.sign(
buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp), buildMemberChangeSignature(
GroupUpdateMemberChangeMessage.Type.REMOVED,
timestamp
),
adminKey adminKey
) )
@ -447,18 +734,339 @@ class GroupManagerV2Impl @Inject constructor(
) )
} }
override suspend fun respondToInvitation(groupId: AccountId, approved: Boolean) = withContext(dispatcher) {
val groups = requireNotNull(configFactory.userGroups) {
"User groups config is not available"
}
val threadId = checkNotNull(storage.getThreadId(Address.fromSerialized(groupId.hexString))) {
"No thread has been created for the group"
}
val group = requireNotNull(groups.getClosedGroup(groupId.hexString)) {
"Group must have been created into the config object before responding to an invitation"
}
// Whether approved or not, delete the invite
lokiDatabase.deleteGroupInviteReferrer(threadId)
if (approved) {
approveGroupInvite(groups, group, threadId)
} else {
groups.eraseClosedGroup(groupId.hexString)
storage.deleteConversation(threadId)
}
}
private fun approveGroupInvite(
groups: UserGroupsConfig,
group: GroupInfo.ClosedGroupInfo,
threadId: Long,
) {
val key = requireNotNull(storage.getUserPublicKey()) {
"Our account ID is not available"
}
// Clear the invited flag of the group in the config
groups.set(group.copy(invited = false))
configFactory.persist(forConfigObject = groups, timestamp = SnodeAPI.nowWithOffset)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application)
if (group.adminKey == null) {
// Send an invite response to the group if we are invited as a regular member
val inviteResponse = GroupUpdateInviteResponseMessage.newBuilder()
.setIsApproved(true)
val responseData = GroupUpdateMessage.newBuilder()
.setInviteResponse(inviteResponse)
val responseMessage = GroupUpdated(responseData.build())
storage.clearMessages(threadId)
// this will fail the first couple of times :)
MessageSender.send(responseMessage, Address.fromSerialized(group.groupAccountId.hexString))
} else {
// If we are invited as admin, we can just update the group info ourselves
configFactory.withGroupConfigsOrNull(group.groupAccountId) { info, members, keys ->
members.get(key)?.let { member ->
members.set(member.setPromoteSuccess().setAccepted())
configFactory.saveGroupConfigs(keys, info, members)
}
Unit
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(
destination = Destination.ClosedGroup(group.groupAccountId.hexString)
)
}
pollerFactory.pollerFor(group.groupAccountId)?.start()
}
override suspend fun onReceiveInvitation(
groupId: AccountId,
groupName: String,
authData: ByteArray,
inviter: AccountId,
inviteMessageHash: String?
) = withContext(dispatcher) {
handleInvitation(
groupId = groupId,
groupName = groupName,
authDataOrAdminKey = authData,
fromPromotion = false,
inviter = inviter
)
// Delete the invite message remotely
if (inviteMessageHash != null) {
val auth = requireNotNull(storage.userAuth) { "No current user available" }
SnodeAPI.sendBatchRequest(
auth.accountId,
SnodeAPI.buildAuthenticatedDeleteBatchInfo(auth, listOf(inviteMessageHash)),
)
}
}
override suspend fun onReceivePromotion(
groupId: AccountId,
groupName: String,
adminKey: ByteArray,
promoter: AccountId,
promoteMessageHash: String?
) = withContext(dispatcher) {
val groups = requireNotNull(configFactory.userGroups) {
"User groups config is not available"
}
val userAuth = requireNotNull(storage.userAuth) { "No current user available" }
var group = groups.getClosedGroup(groupId.hexString)
if (group == null) {
// If we haven't got the group in the config, it could mean that we haven't
// processed the invitation, or the invitation message is lost. We'll need to
// go through the invitation process again.
handleInvitation(
groupId = groupId,
groupName = groupName,
authDataOrAdminKey = adminKey,
fromPromotion = true,
inviter = promoter,
)
} else {
// If we have the group in the config, we can just update the admin key
group = group.copy(adminKey = adminKey)
groups.set(group)
configFactory.persist(groups, SnodeAPI.nowWithOffset)
// Update our promote state
configFactory.withGroupConfigsOrNull(groupId) { info, members, keys ->
members.get(userAuth.accountId.hexString)?.let { member ->
members.set(member.setPromoteSuccess())
configFactory.saveGroupConfigs(keys, info, members)
}
Unit
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(
destination = Destination.ClosedGroup(groupId.hexString)
)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application)
}
// Delete the promotion message remotely
if (promoteMessageHash != null) {
SnodeAPI.sendBatchRequest(
userAuth.accountId,
SnodeAPI.buildAuthenticatedDeleteBatchInfo(userAuth, listOf(promoteMessageHash)),
)
}
}
/**
* Handle an invitation to a group.
*
* @param groupId the group ID
* @param groupName the group name
* @param authDataOrAdminKey the auth data or admin key. If this is an invitation, this is the auth data, if this is a promotion, this is the admin key.
* @param fromPromotion true if this is a promotion, false if this is an invitation
* @param inviter the invite message sender
* @return The newly created group info if the invitation is processed, null otherwise.
*/
private fun handleInvitation(
groupId: AccountId,
groupName: String,
authDataOrAdminKey: ByteArray,
fromPromotion: Boolean,
inviter: AccountId,
) {
val groups = requireNotNull(configFactory.userGroups) {
"User groups config is not available"
}
// If we have already received an invitation in the past, we should not process this one
if (groups.getClosedGroup(groupId.hexString)?.invited == true) {
return
}
val recipient = Recipient.from(application, Address.fromSerialized(groupId.hexString), false)
val shouldAutoApprove = storage.getRecipientApproved(Address.fromSerialized(inviter.hexString))
val closedGroupInfo = GroupInfo.ClosedGroupInfo(
groupAccountId = groupId,
adminKey = authDataOrAdminKey.takeIf { fromPromotion },
authData = authDataOrAdminKey.takeIf { !fromPromotion },
priority = PRIORITY_VISIBLE,
invited = !shouldAutoApprove,
name = groupName,
)
groups.set(closedGroupInfo)
configFactory.persist(groups, SnodeAPI.nowWithOffset)
profileManager.setName(application, recipient, groupName)
val groupThreadId = storage.getOrCreateThreadIdFor(recipient.address)
storage.setRecipientApprovedMe(recipient, true)
storage.setRecipientApproved(recipient, shouldAutoApprove)
if (shouldAutoApprove) {
approveGroupInvite(groups, closedGroupInfo, groupThreadId)
} else {
lokiDatabase.addGroupInviteReferrer(groupThreadId, inviter.hexString)
storage.insertGroupInviteControlMessage(SnodeAPI.nowWithOffset, inviter.hexString, groupId, groupName)
}
}
override suspend fun handleInviteResponse(
groupId: AccountId,
sender: AccountId,
approved: Boolean
): Unit = withContext(dispatcher) {
if (!approved) {
// We should only see approved coming through
return@withContext
}
val groups = requireNotNull(configFactory.userGroups) {
"User groups config is not available"
}
val adminKey = groups.getClosedGroup(groupId.hexString)?.adminKey
if (adminKey == null || adminKey.isEmpty()) {
return@withContext // We don't have the admin key, we can't process the invite response
}
configFactory.withGroupConfigsOrNull(groupId) { info, members, keys ->
val member = members.get(sender.hexString)
if (member == null) {
Log.e(TAG, "User wasn't in the group membership to add!")
return@withContext
}
members.set(member.setAccepted())
configFactory.saveGroupConfigs(keys, info, members)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(
Destination.ClosedGroup(groupId.hexString)
)
}
}
override suspend fun handleKicked(groupId: AccountId): Unit = withContext(dispatcher) {
Log.d(TAG, "We were kicked from the group, delete and stop polling")
// Stop polling the group immediately
pollerFactory.pollerFor(groupId)?.stop()
val userId = requireNotNull(storage.getUserPublicKey()) { "No current user available" }
val userGroups = requireNotNull(configFactory.userGroups) { "User groups config is not available" }
val group = userGroups.getClosedGroup(groupId.hexString) ?: return@withContext
// Retrieve the group name one last time from the group info,
// as we are going to clear the keys, we won't have the chance to
// read the group name anymore.
val groupName = configFactory.getGroupInfoConfig(groupId)
?.use { it.getName() }
?: group.name
userGroups.set(group.copy(
authData = null,
adminKey = null,
name = groupName
))
configFactory.persist(userGroups, SnodeAPI.nowWithOffset)
storage.insertIncomingInfoMessage(
context = MessagingModuleConfiguration.shared.context,
senderPublicKey = userId,
groupID = groupId.hexString,
type = SignalServiceGroup.Type.KICKED,
name = groupName,
members = emptyList(),
admins = emptyList(),
sentTimestamp = SnodeAPI.nowWithOffset,
)
}
override suspend fun setName(groupId: AccountId, newName: String): Unit = withContext(dispatcher) {
val adminKey = requireAdminAccess(groupId)
configFactory.withGroupConfigsOrNull(groupId) { info, members, keys ->
info.setName(newName)
configFactory.saveGroupConfigs(keys, info, members)
}
val groupDestination = Destination.ClosedGroup(groupId.hexString)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
val timestamp = SnodeAPI.nowWithOffset
val signature = SodiumUtilities.sign(
buildInfoChangeVerifier(GroupUpdateInfoChangeMessage.Type.NAME, timestamp),
adminKey
)
val message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setInfoChangeMessage(
GroupUpdateInfoChangeMessage.newBuilder()
.setUpdatedName(newName)
.setType(GroupUpdateInfoChangeMessage.Type.NAME)
.setAdminSignature(ByteString.copyFrom(signature))
)
.build()
).apply {
sentTimestamp = timestamp
}
MessageSender.sendNonDurably(message, Address.fromSerialized(groupId.hexString), false).await()
storage.insertGroupInfoChange(message, groupId)
}
private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) {
val firstError = this.results.firstOrNull { it.code != 200 } val firstError = this.results.firstOrNull { it.code != 200 }
require(firstError == null) { "$errorMessage: ${firstError!!.body}" } require(firstError == null) { "$errorMessage: ${firstError!!.body}" }
} }
private val Contact.profilePicture: UserPic? get() { private val Contact.profilePicture: UserPic?
val url = this.profilePictureURL get() {
val key = this.profilePictureEncryptionKey val url = this.profilePictureURL
return if (url != null && key != null) { val key = this.profilePictureEncryptionKey
UserPic(url, key) return if (url != null && key != null) {
} else { UserPic(url, key)
null } else {
null
}
}
private val Profile.profilePicture: UserPic?
get() {
val url = this.profilePictureURL
val key = this.profileKey
return if (url != null && key != null) {
UserPic(url, key)
} else {
null
}
} }
}
} }

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.withContext
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.userAuth import org.session.libsession.database.userAuth
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.control.UnsendRequest
@ -26,16 +27,15 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.DraftDatabase
import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionJobDatabase import org.thoughtcrime.securesms.database.SessionJobDatabase
import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
@ -69,7 +69,7 @@ interface ConversationRepository {
suspend fun deleteMessageRequest(thread: ThreadRecord): Result<Unit> suspend fun deleteMessageRequest(thread: ThreadRecord): Result<Unit>
suspend fun clearAllMessageRequests(block: Boolean): Result<Unit> suspend fun clearAllMessageRequests(block: Boolean): Result<Unit>
suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): Result<Unit> suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): Result<Unit>
fun declineMessageRequest(threadId: Long, recipient: Recipient) suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result<Unit>
fun hasReceived(threadId: Long): Boolean fun hasReceived(threadId: Long): Boolean
fun getInvitingAdmin(threadId: Long): Recipient? fun getInvitingAdmin(threadId: Long): Recipient?
} }
@ -84,13 +84,12 @@ class DefaultConversationRepository @Inject constructor(
private val smsDb: SmsDatabase, private val smsDb: SmsDatabase,
private val mmsDb: MmsDatabase, private val mmsDb: MmsDatabase,
private val mmsSmsDb: MmsSmsDatabase, private val mmsSmsDb: MmsSmsDatabase,
private val recipientDb: RecipientDatabase,
private val storage: Storage, private val storage: Storage,
private val lokiMessageDb: LokiMessageDatabase, private val lokiMessageDb: LokiMessageDatabase,
private val sessionJobDb: SessionJobDatabase, private val sessionJobDb: SessionJobDatabase,
private val configDb: ExpirationConfigurationDatabase,
private val configFactory: ConfigFactory, private val configFactory: ConfigFactory,
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
private val groupManager: GroupManagerV2,
) : ConversationRepository { ) : ConversationRepository {
override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? {
@ -329,11 +328,8 @@ class DefaultConversationRepository @Inject constructor(
} }
} }
override suspend fun deleteMessageRequest(thread: ThreadRecord) = runCatching { override suspend fun deleteMessageRequest(thread: ThreadRecord)
withContext(Dispatchers.Default) { = declineMessageRequest(thread.threadId, thread.recipient)
declineMessageRequest(thread.threadId, thread.recipient)
}
}
override suspend fun clearAllMessageRequests(block: Boolean) = runCatching { override suspend fun clearAllMessageRequests(block: Boolean) = runCatching {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -353,7 +349,10 @@ class DefaultConversationRepository @Inject constructor(
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
storage.setRecipientApproved(recipient, true) storage.setRecipientApproved(recipient, true)
if (recipient.isClosedGroupV2Recipient) { if (recipient.isClosedGroupV2Recipient) {
storage.respondToClosedGroupInvitation(threadId, recipient, true) groupManager.respondToInvitation(
AccountId(recipient.address.serialize()),
approved = true
)
} else { } else {
val message = MessageRequestResponse(true) val message = MessageRequestResponse(true)
MessageSender.send( MessageSender.send(
@ -369,12 +368,17 @@ class DefaultConversationRepository @Inject constructor(
} }
} }
override fun declineMessageRequest(threadId: Long, recipient: Recipient) { override suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result<Unit> = runCatching {
sessionJobDb.cancelPendingMessageSendJobs(threadId) withContext(Dispatchers.Default) {
if (recipient.isClosedGroupV2Recipient) { sessionJobDb.cancelPendingMessageSendJobs(threadId)
storage.respondToClosedGroupInvitation(threadId, recipient, false) if (recipient.isClosedGroupV2Recipient) {
} else { groupManager.respondToInvitation(
storage.deleteConversation(threadId) AccountId(recipient.address.serialize()),
approved = false
)
} else {
storage.deleteConversation(threadId)
}
} }
} }

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.sskenvironment package org.thoughtcrime.securesms.sskenvironment
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import network.loki.messenger.libsession_util.util.UserPic import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
@ -13,8 +14,14 @@ import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import javax.inject.Inject
import javax.inject.Singleton
class ProfileManager(private val context: Context, private val configFactory: ConfigFactory) : SSKEnvironment.ProfileManagerProtocol { @Singleton
class ProfileManager @Inject constructor(
@ApplicationContext private val context: Context,
private val configFactory: ConfigFactory
) : SSKEnvironment.ProfileManagerProtocol {
override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { override fun setNickname(context: Context, recipient: Recipient, nickname: String?) {
if (recipient.isLocalNumber) return if (recipient.isLocalNumber) return

View File

@ -19,7 +19,6 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.BaseViewModelTest import org.thoughtcrime.securesms.BaseViewModelTest
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
@ -42,8 +41,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
storage = storage, storage = storage,
messageDataProvider = mock(), messageDataProvider = mock(),
groupDb = mock(), groupDb = mock(),
threadDb = mock(), threadDb = mock()
appContext = mock()
) )
} }

View File

@ -170,19 +170,14 @@ interface StorageProtocol {
fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long)
// Closed Groups // Closed Groups
fun createNewGroup(groupName: String, groupDescription: String, members: Set<Contact>): Optional<Recipient>
fun getMembers(groupPublicKey: String): List<LibSessionGroupMember> fun getMembers(groupPublicKey: String): List<LibSessionGroupMember>
fun respondToClosedGroupInvitation(threadId: Long, groupRecipient: Recipient, approved: Boolean)
fun addClosedGroupInvite(groupId: AccountId, name: String, authData: ByteArray?, adminKey: ByteArray?, invitingAdmin: AccountId, invitingMessageHash: String?)
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 insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long? fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long?
fun insertGroupInfoLeaving(closedGroup: AccountId): Long? fun insertGroupInfoLeaving(closedGroup: AccountId): Long?
fun insertGroupInviteControlMessage(sentTimestamp: Long, senderPublicKey: String, closedGroup: AccountId, groupName: String): Long?
fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind)
fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId) fun handleMemberLeftNotification(message: GroupUpdated, closedGroupId: AccountId)
fun handleKicked(groupAccountId: AccountId)
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>
// Groups // Groups

View File

@ -1,6 +1,8 @@
package org.session.libsession.messaging.groups package org.session.libsession.messaging.groups
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.messaging.messages.control.GroupUpdated
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.AccountId
/** /**
@ -8,6 +10,12 @@ import org.session.libsignal.utilities.AccountId
* removing members, promoting members, leaving groups, etc. * removing members, promoting members, leaving groups, etc.
*/ */
interface GroupManagerV2 { interface GroupManagerV2 {
suspend fun createGroup(
groupName: String,
groupDescription: String,
members: Set<Contact>
): Recipient
suspend fun inviteMembers( suspend fun inviteMembers(
group: AccountId, group: AccountId,
newMembers: List<AccountId>, newMembers: List<AccountId>,
@ -25,4 +33,28 @@ interface GroupManagerV2 {
suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean)
suspend fun promoteMember(group: AccountId, members: List<AccountId>) suspend fun promoteMember(group: AccountId, members: List<AccountId>)
suspend fun onReceiveInvitation(
groupId: AccountId,
groupName: String,
authData: ByteArray,
inviter: AccountId,
inviteMessageHash: String?
)
suspend fun onReceivePromotion(
groupId: AccountId,
groupName: String,
adminKey: ByteArray,
promoter: AccountId,
promoteMessageHash: String?
)
suspend fun respondToInvitation(groupId: AccountId, approved: Boolean): Unit?
suspend fun handleInviteResponse(groupId: AccountId, sender: AccountId, approved: Boolean)
suspend fun handleKicked(groupId: AccountId)
suspend fun setName(groupId: AccountId, newName: String)
} }

View File

@ -43,7 +43,6 @@ import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.messaging.utilities.WebRtcUtils
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID
@ -173,7 +172,7 @@ private fun MessageReceiver.handleExpirationTimerUpdate(message: ExpirationTimer
val module = MessagingModuleConfiguration.shared val module = MessagingModuleConfiguration.shared
try { try {
val threadId = fromSerialized(message.groupPublicKey?.let(::doubleEncodeGroupID) ?: message.sender!!) val threadId = Address.fromSerialized(message.groupPublicKey?.let(::doubleEncodeGroupID) ?: message.sender!!)
.let(module.storage::getOrCreateThreadIdFor) .let(module.storage::getOrCreateThreadIdFor)
module.storage.setExpirationConfiguration( module.storage.setExpirationConfiguration(
@ -363,11 +362,18 @@ fun MessageReceiver.handleVisibleMessage(
} }
// Handle group invite response if new closed group // Handle group invite response if new closed group
if (threadRecipient?.isClosedGroupV2Recipient == true) { if (threadRecipient?.isClosedGroupV2Recipient == true) {
storage.setGroupInviteCompleteIfNeeded( GlobalScope.launch {
approved = true, try {
recipient.address.serialize(), MessagingModuleConfiguration.shared.groupManagerV2
AccountId(threadRecipient.address.serialize()) .handleInviteResponse(
) AccountId(threadRecipient.address.serialize()),
AccountId(messageSender),
approved = true
)
} catch (e: Exception) {
Log.e("Loki", "Failed to handle invite response", e)
}
}
} }
// Parse quote if needed // Parse quote if needed
var quoteModel: QuoteModel? = null var quoteModel: QuoteModel? = null
@ -659,20 +665,25 @@ private fun handleGroupInfoChange(message: GroupUpdated, closedGroup: AccountId)
} }
private fun handlePromotionMessage(message: GroupUpdated) { private fun handlePromotionMessage(message: GroupUpdated) {
val storage = MessagingModuleConfiguration.shared.storage
val promotion = message.inner.promoteMessage val promotion = message.inner.promoteMessage
val seed = promotion.groupIdentitySeed.toByteArray() val seed = promotion.groupIdentitySeed.toByteArray()
val keyPair = Sodium.ed25519KeyPair(seed) val keyPair = Sodium.ed25519KeyPair(seed)
val sender = message.sender!! val sender = message.sender!!
val adminId = AccountId(sender) val adminId = AccountId(sender)
storage.addClosedGroupInvite( GlobalScope.launch {
groupId = AccountId(IdPrefix.GROUP, keyPair.pubKey), try {
name = promotion.name, MessagingModuleConfiguration.shared.groupManagerV2
authData = null, .onReceivePromotion(
adminKey = keyPair.secretKey, groupId = AccountId(IdPrefix.GROUP, keyPair.pubKey),
invitingAdmin = adminId, groupName = promotion.name,
message.serverHash adminKey = keyPair.secretKey,
) promoter = adminId,
promoteMessageHash = message.serverHash
)
} catch (e: Exception) {
Log.e("GroupUpdated", "Failed to handle promotion message", e)
}
}
} }
private fun MessageReceiver.handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) { private fun MessageReceiver.handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) {
@ -680,7 +691,13 @@ private fun MessageReceiver.handleInviteResponse(message: GroupUpdated, closedGr
// val profile = message // maybe we do need data to be the inner so we can access profile // val profile = message // maybe we do need data to be the inner so we can access profile
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val approved = message.inner.inviteResponse.isApproved val approved = message.inner.inviteResponse.isApproved
storage.setGroupInviteCompleteIfNeeded(approved, sender, closedGroup) GlobalScope.launch {
try {
MessagingModuleConfiguration.shared.groupManagerV2.handleInviteResponse(closedGroup, AccountId(sender), approved)
} catch (e: Exception) {
Log.e("GroupUpdated", "Failed to handle invite response", e)
}
}
} }
private fun MessageReceiver.handleNewLibSessionClosedGroupMessage(message: GroupUpdated) { private fun MessageReceiver.handleNewLibSessionClosedGroupMessage(message: GroupUpdated) {
@ -696,15 +713,20 @@ private fun MessageReceiver.handleNewLibSessionClosedGroupMessage(message: Group
val sender = message.sender!! val sender = message.sender!!
val adminId = AccountId(sender) val adminId = AccountId(sender)
// add the group GlobalScope.launch {
storage.addClosedGroupInvite( try {
groupId, MessagingModuleConfiguration.shared.groupManagerV2
invite.name, .onReceiveInvitation(
invite.memberAuthData.toByteArray(), groupId = groupId,
null, groupName = invite.name,
adminId, authData = invite.memberAuthData.toByteArray(),
message.serverHash inviter = adminId,
) inviteMessageHash = message.serverHash
)
} catch (e: Exception) {
Log.e("GroupUpdated", "Failed to handle invite message", e)
}
}
} }
/** /**
@ -768,7 +790,7 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli
storage.updateTitle(groupID, name) storage.updateTitle(groupID, name)
storage.updateMembers(groupID, members.map { Address.fromSerialized(it) }) storage.updateMembers(groupID, members.map { Address.fromSerialized(it) })
} else { } else {
storage.createGroup(groupID, name, LinkedList(members.map { fromSerialized(it) }), storage.createGroup(groupID, name, LinkedList(members.map { Address.fromSerialized(it) }),
null, null, LinkedList(admins.map { Address.fromSerialized(it) }), formationTimestamp) null, null, LinkedList(admins.map { Address.fromSerialized(it) }), formationTimestamp)
} }
storage.setProfileSharing(Address.fromSerialized(groupID), true) storage.setProfileSharing(Address.fromSerialized(groupID), true)

View File

@ -2,6 +2,7 @@ package org.session.libsession.messaging.sending_receiving.pollers
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -13,6 +14,7 @@ import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.Sodium import network.loki.messenger.libsession_util.util.Sodium
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.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.BatchMessageReceiveJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.MessageReceiveParameters
@ -38,7 +40,7 @@ class ClosedGroupPoller(
private val executor: CoroutineDispatcher, private val executor: CoroutineDispatcher,
private val closedGroupSessionId: AccountId, private val closedGroupSessionId: AccountId,
private val configFactoryProtocol: ConfigFactoryProtocol, private val configFactoryProtocol: ConfigFactoryProtocol,
private val storageProtocol: StorageProtocol = MessagingModuleConfiguration.shared.storage) { private val groupManagerV2: GroupManagerV2) {
data class ParsedRawMessage( data class ParsedRawMessage(
val data: ByteArray, val data: ByteArray,
@ -268,40 +270,14 @@ class ClosedGroupPoller(
if (Sodium.KICKED_REGEX.matches(message)) { if (Sodium.KICKED_REGEX.matches(message)) {
val (sessionId, generation) = message.split("-") val (sessionId, generation) = message.split("-")
if (sessionId == userSessionId.hexString && generation.toInt() >= keys.currentGeneration()) { if (sessionId == userSessionId.hexString && generation.toInt() >= keys.currentGeneration()) {
Log.d("GroupPoller", "We were kicked from the group, delete and stop polling") GlobalScope.launch {
stop() try {
groupManagerV2.handleKicked(closedGroupSessionId)
configFactoryProtocol.userGroups?.let { userGroups -> } catch (e: Exception) {
userGroups.getClosedGroup(closedGroupSessionId.hexString)?.let { group -> Log.e("GroupPoller", "Error handling kicked message: $e")
// Retrieve the group name one last time from the group info,
// as we are going to clear the keys, we won't have the chance to
// read the group name anymore.
val groupName = configFactoryProtocol.getGroupInfoConfig(closedGroupSessionId)
?.use { it.getName() }
?: group.name
userGroups.set(group.copy(
authData = null,
adminKey = null,
name = groupName
))
configFactoryProtocol.persist(userGroups, SnodeAPI.nowWithOffset)
} }
} }
storageProtocol.handleKicked(closedGroupSessionId)
MessagingModuleConfiguration.shared.storage.insertIncomingInfoMessage(
context = MessagingModuleConfiguration.shared.context,
senderPublicKey = userSessionId.hexString,
groupID = closedGroupSessionId.hexString,
type = SignalServiceGroup.Type.KICKED,
name = "",
members = emptyList(),
admins = emptyList(),
sentTimestamp = SnodeAPI.nowWithOffset,
)
} }
} }
} }

View File

@ -2,6 +2,8 @@
package org.session.libsession.snode package org.session.libsession.snode
import android.os.SystemClock
import com.fasterxml.jackson.databind.JsonNode
import com.goterl.lazysodium.exceptions.SodiumException import com.goterl.lazysodium.exceptions.SodiumException
import com.goterl.lazysodium.interfaces.GenericHash import com.goterl.lazysodium.interfaces.GenericHash
import com.goterl.lazysodium.interfaces.PwHash import com.goterl.lazysodium.interfaces.PwHash
@ -9,8 +11,18 @@ import com.goterl.lazysodium.interfaces.SecretBox
import com.goterl.lazysodium.utils.Key import com.goterl.lazysodium.utils.Key
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all import nl.komponents.kovenant.all
import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.bind
@ -43,6 +55,8 @@ import kotlin.collections.component1
import kotlin.collections.component2 import kotlin.collections.component2
import kotlin.collections.set import kotlin.collections.set
import kotlin.properties.Delegates.observable import kotlin.properties.Delegates.observable
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object SnodeAPI { object SnodeAPI {
internal val database: LokiAPIDatabaseProtocol internal val database: LokiAPIDatabaseProtocol
@ -115,7 +129,7 @@ object SnodeAPI {
val method: String, val method: String,
val params: Map<String, Any>, val params: Map<String, Any>,
@Transient @Transient
val namespace: Int? val namespace: Int?,
) // assume signatures, pubkey and namespaces are attached in parameters if required ) // assume signatures, pubkey and namespaces are attached in parameters if required
// Internal API // Internal API
@ -571,6 +585,99 @@ object SnodeAPI {
} }
} }
private data class RequestInfo(
val accountId: AccountId,
val request: SnodeBatchRequestInfo,
val responseType: Class<*>,
val callback: SendChannel<Result<Any>>,
val requestTime: Long = SystemClock.uptimeMillis(),
)
private val batchedRequestsSender: SendChannel<RequestInfo>
init {
val batchRequests = Channel<RequestInfo>()
batchedRequestsSender = batchRequests
val batchWindowMills = 100L
@Suppress("OPT_IN_USAGE")
GlobalScope.launch {
val batches = hashMapOf<AccountId, MutableList<RequestInfo>>()
while (true) {
val batch = select<List<RequestInfo>?> {
// If we receive a request, add it to the batch
batchRequests.onReceive {
batches.getOrPut(it.accountId) { mutableListOf() }.add(it)
null
}
// If we have anything in the batch, look for the one that is about to expire
// and wait for it to expire, remove it from the batches and send it for
// processing.
if (batches.isNotEmpty()) {
val earliestBatch = batches.minBy { it.value.first().requestTime }
val deadline = earliestBatch.value.first().requestTime + batchWindowMills
onTimeout(
timeMillis = (deadline - SystemClock.uptimeMillis()).coerceAtLeast(0)
) {
batches.remove(earliestBatch.key)
}
}
}
if (batch != null) {
launch {
val accountId = batch.first().accountId
val responses = try {
getBatchResponse(
snode = getSingleTargetSnode(accountId.hexString).await(),
publicKey = accountId.hexString,
requests = batch.map { it.request }, sequence = false
)
} catch (e: Exception) {
for (req in batch) {
req.callback.send(Result.failure(e))
}
return@launch
}
for ((req, resp) in batch.zip(responses.results)) {
req.callback.send(kotlin.runCatching {
JsonUtil.fromJson(resp.body, req.responseType)
})
}
// Close all channels in the requests just in case we don't have paired up
// responses.
for (req in batch) {
req.callback.close()
}
}
}
}
}
}
suspend fun <T> sendBatchRequest(
swarmAccount: AccountId,
request: SnodeBatchRequestInfo,
responseType: Class<T>,
): T {
val callback = Channel<Result<T>>()
@Suppress("UNCHECKED_CAST")
batchedRequestsSender.send(RequestInfo(swarmAccount, request, responseType, callback as SendChannel<Any>))
return callback.receive().getOrThrow()
}
suspend fun sendBatchRequest(
swarmAccount: AccountId,
request: SnodeBatchRequestInfo,
): JsonNode {
return sendBatchRequest(swarmAccount, request, JsonNode::class.java)
}
suspend fun getBatchResponse( suspend fun getBatchResponse(
snode: Snode, snode: Snode,
publicKey: String, publicKey: String,
@ -697,8 +804,15 @@ object SnodeAPI {
return scope.retrySuspendAsPromise(maxRetryCount) { return scope.retrySuspendAsPromise(maxRetryCount) {
val destination = message.recipient val destination = message.recipient
val snode = getSingleTargetSnode(destination).await() sendBatchRequest(
invoke(Snode.Method.SendMessage, snode, params, destination).await() swarmAccount = AccountId(destination),
request = SnodeBatchRequestInfo(
method = Snode.Method.SendMessage.rawValue,
params = params,
namespace = namespace
),
responseType = Map::class.java
)
} }
} }

View File

@ -2,12 +2,13 @@ package org.session.libsession.snode.model
import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.JsonNode
data class BatchResponse @JsonCreator constructor( data class BatchResponse @JsonCreator constructor(
@param:JsonProperty("results") val results: List<Item>, @param:JsonProperty("results") val results: List<Item>,
) { ) {
data class Item @JsonCreator constructor( data class Item @JsonCreator constructor(
@param:JsonProperty("code") val code: Int, @param:JsonProperty("code") val code: Int,
@param:JsonProperty("body") val body: Map<String, Any?>?, @param:JsonProperty("body") val body: JsonNode,
) )
} }

View File

@ -0,0 +1,9 @@
package org.session.libsession.snode.model
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
data class StoreMessageResponse @JsonCreator constructor(
@JsonProperty("hash") val hash: String,
@JsonProperty("t") val timestamp: Long,
)

View File

@ -51,6 +51,10 @@ public class JsonUtil {
return objectMapper.readValue(serialized, clazz); return objectMapper.readValue(serialized, clazz);
} }
public static <T> T fromJson(JsonNode serialized, Class<T> clazz) throws IOException {
return objectMapper.treeToValue(serialized, clazz);
}
public static JsonNode fromJson(String serialized) throws IOException { public static JsonNode fromJson(String serialized) throws IOException {
return objectMapper.readTree(serialized); return objectMapper.readTree(serialized);
} }