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