Improvement

This commit is contained in:
SessionHero01
2024-09-24 16:26:29 +10:00
parent 80e3e563ce
commit 8c1eb1550b
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 TypingStatusSender typingStatusSender;
private ReadReceiptManager readReceiptManager;
private ProfileManager profileManager;
public MessageNotifier messageNotifier = null;
public Poller poller = null;
public Broadcaster broadcaster = null;
@@ -166,6 +166,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
PushRegistrationHandler pushRegistrationHandler;
@Inject TokenFetcher tokenFetcher;
@Inject GroupManagerV2 groupManagerV2;
@Inject SSKEnvironment.ProfileManagerProtocol profileManager;
CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration;
@@ -268,9 +269,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
initializeTypingStatusRepository();
initializeTypingStatusSender();
initializeReadReceiptManager();
initializeProfileManager();
initializePeriodicTasks();
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager());
SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), profileManager, messageNotifier, getExpiringMessageManager());
initializeWebRtc();
initializeBlobProvider();
resubmitProfilePictureIfNeeded();
@@ -368,9 +368,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return readReceiptManager;
}
public ProfileManager getProfileManager() {
return profileManager;
}
public boolean isAppVisible() {
return isAppVisible;
@@ -426,10 +423,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
this.readReceiptManager = new ReadReceiptManager();
}
private void initializeProfileManager() {
this.profileManager = new ProfileManager(this, configFactory);
}
private void initializeTypingStatusSender() {
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.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.GroupMember
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.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID
class ConversationViewModel(
@@ -47,7 +45,6 @@ class ConversationViewModel(
private val messageDataProvider: MessageDataProvider,
private val groupDb: GroupDatabase,
private val threadDb: ThreadDatabase,
private val appContext: Context,
) : ViewModel() {
val showSendAfterApprovalText: Boolean
@@ -348,10 +345,6 @@ class ConversationViewModel(
_uiState.update {
it.copy(messageRequestState = MessageRequestUiState.Invisible)
}
withContext(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
}
}
.onFailure {
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!!)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(appContext)
_uiState.update { it.copy(shouldExit = true) }
.onSuccess {
_uiState.update { it.copy(shouldExit = true) }
}
.onFailure {
showMessage("Couldn't decline message request due to error: $it")
}
}
private fun showMessage(message: String) {
@@ -456,7 +453,6 @@ class ConversationViewModel(
messageDataProvider = messageDataProvider,
groupDb = groupDb,
threadDb = threadDb,
appContext = context,
) as T
}
}

View File

@@ -1079,137 +1079,6 @@ open class Storage(
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) {
val volatiles = configFactory.convoVolatile ?: return
val userGroups = configFactory.userGroups ?: return
@@ -1375,141 +1244,6 @@ open class Storage(
override fun getMembers(groupPublicKey: String): List<LibSessionGroupMember> =
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? {
return configFactory.userGroups?.getClosedGroup(groupSessionId)
@@ -1557,7 +1291,7 @@ open class Storage(
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))
return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
}
@@ -1610,46 +1344,6 @@ open class Storage(
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> {
val closedGroup = configFactory.userGroups?.getClosedGroup(groupSessionId)

View File

@@ -11,11 +11,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.Toaster
import org.thoughtcrime.securesms.groups.GroupManagerV2Impl
import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.repository.DefaultConversationRepository
import org.thoughtcrime.securesms.sskenvironment.ProfileManager
import javax.inject.Singleton
@Module
@@ -30,6 +32,9 @@ abstract class AppModule {
@Binds
abstract fun bindGroupManager(groupManager: GroupManagerV2Impl): GroupManagerV2
@Binds
abstract fun bindProfileManager(profileManager: ProfileManager): SSKEnvironment.ProfileManagerProtocol
}
@Module

View File

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

View File

@@ -3,10 +3,25 @@ package org.thoughtcrime.securesms.groups
import android.content.Context
import com.google.protobuf.ByteString
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.Sodium
import network.loki.messenger.libsession_util.util.UserPic
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.groups.GroupManagerV2
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.messages.Destination
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.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.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.model.StoreMessageResponse
import org.session.libsession.snode.utilities.await
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.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos.DataMessage
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.GroupUpdateMessage
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Namespace
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
@@ -40,15 +64,20 @@ import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "GroupManagerV2Impl"
@Singleton
class GroupManagerV2Impl @Inject constructor(
val storage: StorageProtocol,
val configFactory: ConfigFactory,
val mmsSmsDatabase: MmsSmsDatabase,
val lokiDatabase: LokiMessageDatabase,
val pollerFactory: PollerFactory,
private val storage: StorageProtocol,
private val configFactory: ConfigFactory,
private val mmsSmsDatabase: MmsSmsDatabase,
private val lokiDatabase: LokiMessageDatabase,
private val pollerFactory: PollerFactory,
private val profileManager: SSKEnvironment.ProfileManagerProtocol,
@ApplicationContext val application: Context,
) : GroupManagerV2 {
private val dispatcher = Dispatchers.Default
/**
* 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" }
}
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(
group: AccountId,
newMembers: List<AccountId>,
shareHistory: Boolean
) {
): Unit = withContext(dispatcher) {
val adminKey = requireAdminAccess(group)
val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey)
@@ -76,7 +308,10 @@ class GroupManagerV2Impl @Inject constructor(
val toSet = membersConfig.get(newMember.hexString)
?.let { existing ->
if (existing.inviteFailed || existing.invitePending) {
existing.copy(inviteStatus = INVITE_STATUS_SENT, supplement = shareHistory)
existing.copy(
inviteStatus = INVITE_STATUS_SENT,
supplement = shareHistory
)
} else {
existing
}
@@ -105,16 +340,18 @@ class GroupManagerV2Impl @Inject constructor(
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,
))
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)
@@ -150,7 +387,12 @@ class GroupManagerV2Impl @Inject constructor(
configFactory.saveGroupConfigs(keysConfig, infoConfig, membersConfig)
// 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
val signature = SodiumUtilities.sign(
@@ -216,7 +458,8 @@ class GroupManagerV2Impl @Inject constructor(
}
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)
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)
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())
// Promote the members by sending a message containing the admin key to each member's swarm,
// we do this concurrently and then update the group configs after all the messages are sent.
val promoteResult = members.asSequence()
.mapNotNull { membersConfig.get(it.hexString) }
.map { memberConfig ->
async {
val message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setPromoteMessage(
DataMessage.GroupUpdatePromoteMessage.newBuilder()
.setGroupIdentitySeed(ByteString.copyFrom(adminKey))
.setName(info.getName())
)
.build()
)
.build()
)
MessageSender.send(message, Address.fromSerialized(group.hexString))
try {
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)
}
// Send a group update message to the group telling members someone has been promoted
val groupDestination = Destination.ClosedGroup(group.hexString)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(groupDestination)
val timestamp = SnodeAPI.nowWithOffset
@@ -294,14 +557,16 @@ class GroupManagerV2Impl @Inject constructor(
sentTimestamp = timestamp
}
MessageSender.send(message, Address.fromSerialized(groupDestination.publicKey))
MessageSender.send(message, Address.fromSerialized(group.hexString))
storage.insertGroupInfoChange(message, group)
}
private suspend fun doRemoveMembers(group: AccountId,
removedMembers: List<AccountId>,
sendRemovedMessage: Boolean,
removeMemberMessages: Boolean) {
private suspend fun doRemoveMembers(
group: AccountId,
removedMembers: List<AccountId>,
sendRemovedMessage: Boolean,
removeMemberMessages: Boolean
) = withContext(dispatcher) {
val adminKey = requireAdminAccess(group)
val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey)
@@ -324,7 +589,8 @@ class GroupManagerV2Impl @Inject constructor(
)
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(),
ed25519SecretKey = adminKey,
domain = Sodium.KICKED_DOMAIN
@@ -345,22 +611,24 @@ class GroupManagerV2Impl @Inject constructor(
val adminSignature =
SodiumUtilities.sign(
buildDeleteMemberContentSignature(
memberIds = removedMembers,
messageHashes = emptyList(),
timestamp = messageSendTimestamp
), adminKey)
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()
message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setDeleteMemberContent(
GroupUpdateDeleteMemberContentMessage.newBuilder()
.addAllMemberSessionIds(removedMembers.map { it.hexString })
.setAdminSignature(ByteString.copyFrom(adminSignature))
)
.build()
).apply { sentTimestamp = messageSendTimestamp },
isSyncMessage = false
),
@@ -370,7 +638,12 @@ class GroupManagerV2Impl @Inject constructor(
}
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")
@@ -403,9 +676,20 @@ class GroupManagerV2Impl @Inject constructor(
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)
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(
@@ -422,7 +706,10 @@ class GroupManagerV2Impl @Inject constructor(
if (sendRemovedMessage) {
val timestamp = messageSendTimestamp
val signature = SodiumUtilities.sign(
buildMemberChangeSignature(GroupUpdateMemberChangeMessage.Type.REMOVED, timestamp),
buildMemberChangeSignature(
GroupUpdateMemberChangeMessage.Type.REMOVED,
timestamp
),
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) {
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
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
}
}
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 org.session.libsession.database.MessageDataProvider
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.control.MessageRequestResponse
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.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.DraftDatabase
import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionJobDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
@@ -69,7 +69,7 @@ interface ConversationRepository {
suspend fun deleteMessageRequest(thread: ThreadRecord): Result<Unit>
suspend fun clearAllMessageRequests(block: Boolean): 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 getInvitingAdmin(threadId: Long): Recipient?
}
@@ -84,13 +84,12 @@ class DefaultConversationRepository @Inject constructor(
private val smsDb: SmsDatabase,
private val mmsDb: MmsDatabase,
private val mmsSmsDb: MmsSmsDatabase,
private val recipientDb: RecipientDatabase,
private val storage: Storage,
private val lokiMessageDb: LokiMessageDatabase,
private val sessionJobDb: SessionJobDatabase,
private val configDb: ExpirationConfigurationDatabase,
private val configFactory: ConfigFactory,
private val contentResolver: ContentResolver,
private val groupManager: GroupManagerV2,
) : ConversationRepository {
override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? {
@@ -329,11 +328,8 @@ class DefaultConversationRepository @Inject constructor(
}
}
override suspend fun deleteMessageRequest(thread: ThreadRecord) = runCatching {
withContext(Dispatchers.Default) {
declineMessageRequest(thread.threadId, thread.recipient)
}
}
override suspend fun deleteMessageRequest(thread: ThreadRecord)
= declineMessageRequest(thread.threadId, thread.recipient)
override suspend fun clearAllMessageRequests(block: Boolean) = runCatching {
withContext(Dispatchers.Default) {
@@ -353,7 +349,10 @@ class DefaultConversationRepository @Inject constructor(
withContext(Dispatchers.Default) {
storage.setRecipientApproved(recipient, true)
if (recipient.isClosedGroupV2Recipient) {
storage.respondToClosedGroupInvitation(threadId, recipient, true)
groupManager.respondToInvitation(
AccountId(recipient.address.serialize()),
approved = true
)
} else {
val message = MessageRequestResponse(true)
MessageSender.send(
@@ -369,12 +368,17 @@ class DefaultConversationRepository @Inject constructor(
}
}
override fun declineMessageRequest(threadId: Long, recipient: Recipient) {
sessionJobDb.cancelPendingMessageSendJobs(threadId)
if (recipient.isClosedGroupV2Recipient) {
storage.respondToClosedGroupInvitation(threadId, recipient, false)
} else {
storage.deleteConversation(threadId)
override suspend fun declineMessageRequest(threadId: Long, recipient: Recipient): Result<Unit> = runCatching {
withContext(Dispatchers.Default) {
sessionJobDb.cancelPendingMessageSendJobs(threadId)
if (recipient.isClosedGroupV2Recipient) {
groupManager.respondToInvitation(
AccountId(recipient.address.serialize()),
approved = false
)
} else {
storage.deleteConversation(threadId)
}
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.sskenvironment
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.messaging.contacts.Contact
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.DatabaseComponent
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?) {
if (recipient.isLocalNumber) return

View File

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