Clock management and kicked

This commit is contained in:
SessionHero01
2024-10-03 12:15:51 +10:00
parent 1f5fde0d9a
commit a5c89d8d5a
17 changed files with 429 additions and 290 deletions

View File

@@ -48,6 +48,7 @@ import org.session.libsession.messaging.notifications.TokenFetcher;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2;
import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeClock;
import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Device;
@@ -165,6 +166,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
MessagingModuleConfiguration messagingModuleConfiguration;
@Inject ConfigSyncHandler configSyncHandler;
@Inject RemoveGroupMemberHandler removeGroupMemberHandler;
@Inject SnodeClock snodeClock;
private volatile boolean isAppVisible;
@@ -236,7 +238,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
lastSentTimestampCache,
this,
tokenFetcher,
groupManagerV2
groupManagerV2,
snodeClock
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
@@ -270,6 +273,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
pushRegistrationHandler.run();
configSyncHandler.start();
removeGroupMemberHandler.start();
snodeClock.start();
// add our shortcut debug menu if we are not in a release build
if (BuildConfig.BUILD_TYPE != "release") {

View File

@@ -327,8 +327,6 @@ class ConfigFactory @Inject constructor(
cb(configs as GroupConfigsImpl)
}
Log.d("ConfigFactory", "Group updated? $groupId: $changed")
if (changed) {
if (!_configUpdateNotifications.tryEmit(ConfigUpdateNotification.GroupConfigsUpdated(groupId))) {
Log.e("ConfigFactory", "Unable to deliver group update notification")
@@ -351,6 +349,7 @@ class ConfigFactory @Inject constructor(
override fun removeGroup(groupId: AccountId) {
withMutableUserConfigs {
it.userGroups.eraseClosedGroup(groupId.hexString)
it.convoInfoVolatile.eraseClosedGroup(groupId.hexString)
}
if (groupConfigs.remove(groupId) != null) {

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.snode.SnodeClock
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.thoughtcrime.securesms.database.ConfigDatabase
@@ -52,4 +53,8 @@ object SessionUtilModule {
storage = storage,
lokiApiDatabase = lokiApiDatabase,
)
@Provides
@Singleton
fun provideSnodeClock() = SnodeClock()
}

View File

@@ -8,6 +8,7 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -242,14 +243,13 @@ class EditGroupViewModel @AssistedInject constructor(
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 {
// any group operation to be cancelled when the view model is cleared.
val task = GlobalScope.async {
operation()
}
try {
task.join()
task.await()
} catch (e: Exception) {
mutableError.value = e.localizedMessage.orEmpty()
} finally {

View File

@@ -3,7 +3,10 @@ package org.thoughtcrime.securesms.groups
import android.content.Context
import com.google.protobuf.ByteString
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE
@@ -12,7 +15,6 @@ 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_FAILED
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
@@ -38,6 +40,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.getClosedGroup
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.waitUntilGroupConfigsPushed
import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos.DataMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage
@@ -76,10 +79,11 @@ class GroupManagerV2Impl @Inject constructor(
* @throws IllegalArgumentException if the group does not exist or no admin key is found.
*/
private fun requireAdminAccess(group: AccountId): ByteArray {
return checkNotNull(configFactory
.withUserConfigs { it.userGroups.getClosedGroup(group.hexString) }
?.adminKey
?.takeIf { it.isNotEmpty() }) { "Only admin is allowed to invite members" }
return checkNotNull(
configFactory.getClosedGroup(group)
?.adminKey
?.takeIf { it.isNotEmpty() }
) { "Only admin is allowed to invite members" }
}
override suspend fun createGroup(
@@ -95,7 +99,9 @@ class GroupManagerV2Impl @Inject constructor(
// Create a group in the user groups config
val group = configFactory.withMutableUserConfigs { configs ->
configs.userGroups.createGroup().also(configs.userGroups::set)
configs.userGroups.createGroup()
.copy(name = groupName)
.also(configs.userGroups::set)
}
checkNotNull(group.adminKey) { "Admin key is null for new group creation." }
@@ -133,6 +139,10 @@ class GroupManagerV2Impl @Inject constructor(
configs.rekey()
}
if (!configFactory.waitUntilGroupConfigsPushed(groupId)) {
Log.w(TAG, "Unable to push group configs in a timely manner")
}
configFactory.withMutableUserConfigs {
it.convoInfoVolatile.set(
Conversation.ClosedGroup(
@@ -281,77 +291,105 @@ class GroupManagerV2Impl @Inject constructor(
removedMembers: List<AccountId>,
removeMessages: Boolean
) {
doRemoveMembers(
flagMembersForRemoval(
group = groupAccountId,
removedMembers = removedMembers,
sendRemovedMessage = true,
removeMemberMessages = removeMessages
members = removedMembers,
alsoRemoveMembersMessage = removeMessages,
sendMemberChangeMessage = true
)
}
override suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) {
val closedGroupHexString = closedGroupId.hexString
val closedGroup =
configFactory.withUserConfigs { it.userGroups.getClosedGroup(closedGroupId.hexString) }
?: return
override suspend fun removeMemberMessages(
groupAccountId: AccountId,
members: List<AccountId>
): Unit = withContext(dispatcher) {
val messagesToDelete = mutableListOf<String>()
val threadId = storage.getThreadId(Address.fromSerialized(groupAccountId.hexString))
if (threadId != null) {
for (member in members) {
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)
}
}
if (messagesToDelete.isEmpty()) {
return@withContext
}
val groupAdminAuth = configFactory.getClosedGroup(groupAccountId)?.adminKey?.let {
OwnedSwarmAuth.ofClosedGroup(groupAccountId, it)
} ?: return@withContext
SnodeAPI.deleteMessage(groupAccountId.hexString, groupAdminAuth, messagesToDelete).await()
}
override suspend fun handleMemberLeft(message: GroupUpdated, group: AccountId) {
val closedGroup = configFactory.getClosedGroup(group) ?: 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
flagMembersForRemoval(
group = group,
members = listOf(AccountId(message.sender!!)),
alsoRemoveMembersMessage = false,
sendMemberChangeMessage = false
)
} else {
val hasAnyAdminRemaining = configFactory.withGroupConfigs(closedGroupId) { configs ->
val hasAnyAdminRemaining = configFactory.withGroupConfigs(group) { configs ->
configs.groupMembers.all()
.asSequence()
.filterNot { it.sessionId == message.sender }
.any { it.admin && !it.removed }
}
// 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 the leaving member is last admin, disable the group and remove it
// This is just to emulate the "existing" group behaviour, this will probably be removed in future
if (!hasAnyAdminRemaining) {
pollerFactory.pollerFor(closedGroupId)?.stop()
storage.getThreadId(Address.fromSerialized(closedGroupHexString))
pollerFactory.pollerFor(group)?.stop()
storage.getThreadId(Address.fromSerialized(group.hexString))
?.let(storage::deleteConversation)
configFactory.removeGroup(closedGroupId)
configFactory.removeGroup(group)
}
}
}
override suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) {
val canSendGroupMessage =
configFactory.withUserConfigs { it.userGroups.getClosedGroup(group.hexString) }?.kicked != true
val address = Address.fromSerialized(group.hexString)
val canSendGroupMessage = configFactory.getClosedGroup(group)?.kicked == false
if (canSendGroupMessage) {
MessageSender.sendNonDurably(
message = GroupUpdated(
val destination = Destination.ClosedGroup(group.hexString)
MessageSender.send(
GroupUpdated(
GroupUpdateMessage.newBuilder()
.setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance())
.build()
),
address = address,
destination,
isSyncMessage = false
).await()
MessageSender.sendNonDurably(
message = GroupUpdated(
MessageSender.send(
GroupUpdated(
GroupUpdateMessage.newBuilder()
.setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance())
.build()
),
address = address,
destination,
isSyncMessage = false
).await()
}
pollerFactory.pollerFor(group)?.stop()
// TODO: set "deleted" and post to -10 group namespace?
if (deleteOnLeave) {
storage.getThreadId(address)?.let(storage::deleteConversation)
storage.getThreadId(Address.fromSerialized(group.hexString))
?.let(storage::deleteConversation)
configFactory.removeGroup(group)
}
}
@@ -359,25 +397,25 @@ class GroupManagerV2Impl @Inject constructor(
override suspend fun promoteMember(
group: AccountId,
members: List<AccountId>
): Unit = withContext(dispatcher) {
): Unit = withContext(dispatcher + SupervisorJob()) {
val adminKey = requireAdminAccess(group)
val groupName = configFactory.withGroupConfigs(group) { it.groupInfo.getName() }
// Send out the promote message to the members concurrently
val promoteMessage = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setPromoteMessage(
DataMessage.GroupUpdatePromoteMessage.newBuilder()
.setGroupIdentitySeed(ByteString.copyFrom(adminKey))
.setName(groupName)
)
.build()
)
val promotionDeferred = members.associateWith { member ->
async {
val message = GroupUpdated(
GroupUpdateMessage.newBuilder()
.setPromoteMessage(
DataMessage.GroupUpdatePromoteMessage.newBuilder()
.setGroupIdentitySeed(ByteString.copyFrom(adminKey))
.setName(groupName)
)
.build()
)
MessageSender.sendNonDurably(
message = message,
message = promoteMessage,
address = Address.fromSerialized(member.hexString),
isSyncMessage = false
).await()
@@ -428,125 +466,25 @@ class GroupManagerV2Impl @Inject constructor(
storage.insertGroupInfoChange(message, group)
}
private suspend fun doRemoveMembers(
group: AccountId,
removedMembers: List<AccountId>,
sendRemovedMessage: Boolean,
removeMemberMessages: Boolean
) = withContext(dispatcher) {
private suspend fun flagMembersForRemoval(
group: AccountId, members: List<AccountId>,
alsoRemoveMembersMessage: Boolean,
sendMemberChangeMessage: Boolean
) {
val adminKey = requireAdminAccess(group)
val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey)
// 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 essentialRequests = configFactory.withGroupConfigs(group) { configs ->
val messageSendTimestamp = SnodeAPI.nowWithOffset
buildList {
this += SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest(
groupAdminAuth = groupAuth,
subAccountTokens = removedMembers.map(configs.groupKeys::getSubAccountToken)
)
this += Sodium.encryptForMultipleSimple(
messages = removedMembers.map { "${it.hexString}-${configs.groupKeys.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
// 1. Mark the members as removed in the group configs
configFactory.withMutableGroupConfigs(group) { configs ->
removedMembers.forEach { configs.groupMembers.erase(it.hexString) }
configs.rekey()
}
if (removeMemberMessages) {
val threadId = storage.getThreadId(Address.fromSerialized(group.hexString))
if (threadId != null) {
val messagesToDelete = mutableListOf<String>()
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)
for (member in members) {
val memberConfig = configs.groupMembers.get(member.hexString)
if (memberConfig != null) {
configs.groupMembers.set(memberConfig.setRemoved(alsoRemoveMembersMessage))
}
SnodeAPI.sendBatchRequest(
snode, group.hexString, SnodeAPI.buildAuthenticatedDeleteBatchInfo(
groupAuth,
messagesToDelete
)
)
}
}
if (sendRemovedMessage) {
// 2. Send a member change message
if (sendMemberChangeMessage) {
val timestamp = SnodeAPI.nowWithOffset
val signature = SodiumUtilities.sign(
buildMemberChangeSignature(
@@ -559,7 +497,7 @@ class GroupManagerV2Impl @Inject constructor(
val updateMessage = GroupUpdateMessage.newBuilder()
.setMemberChangeMessage(
GroupUpdateMemberChangeMessage.newBuilder()
.addAllMemberSessionIds(removedMembers.map { it.hexString })
.addAllMemberSessionIds(members.map { it.hexString })
.setType(GroupUpdateMemberChangeMessage.Type.REMOVED)
.setAdminSignature(ByteString.copyFrom(signature))
)
@@ -567,8 +505,8 @@ class GroupManagerV2Impl @Inject constructor(
val message = GroupUpdated(
updateMessage
).apply { sentTimestamp = timestamp }
MessageSender.send(message, Destination.ClosedGroup(group.hexString), false)
storage.insertGroupInfoChange(message, group)
MessageSender.send(message, Destination.ClosedGroup(group.hexString), false).await()
}
}
@@ -671,8 +609,7 @@ class GroupManagerV2Impl @Inject constructor(
promoteMessageHash: String?
) = withContext(dispatcher) {
val userAuth = requireNotNull(storage.userAuth) { "No current user available" }
val group =
configFactory.withUserConfigs { it.userGroups.getClosedGroup(groupId.hexString) }
val group = configFactory.getClosedGroup(groupId)
if (group == null) {
// If we haven't got the group in the config, it could mean that we haven't
@@ -692,12 +629,13 @@ class GroupManagerV2Impl @Inject constructor(
}
// Update our promote state
configFactory.withMutableGroupConfigs(recreateConfigInstances = true, groupId = groupId) { configs ->
configFactory.withMutableGroupConfigs(
recreateConfigInstances = true,
groupId = groupId
) { configs ->
configs.groupMembers.get(userAuth.accountId.hexString)?.let { member ->
configs.groupMembers.set(member.setPromoteSuccess())
}
Unit
}
}
@@ -729,7 +667,7 @@ class GroupManagerV2Impl @Inject constructor(
inviter: AccountId,
) {
// If we have already received an invitation in the past, we should not process this one
if (configFactory.withUserConfigs { it.userGroups.getClosedGroup(groupId.hexString) }?.invited == true) {
if (configFactory.getClosedGroup(groupId)?.invited == true) {
return
}
@@ -893,8 +831,11 @@ class GroupManagerV2Impl @Inject constructor(
// If we are admin, we can delete the messages from the group swarm
group.adminKey?.let { adminKey ->
SnodeAPI.deleteMessage(groupId.hexString, OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), messageHashes)
.await()
SnodeAPI.deleteMessage(
publicKey = groupId.hexString,
swarmAuth = OwnedSwarmAuth.ofClosedGroup(groupId, adminKey),
serverHashes = messageHashes
).await()
}
// Construct a message to ask members to delete the messages, sign if we are admin, then send
@@ -978,8 +919,11 @@ class GroupManagerV2Impl @Inject constructor(
groupId.hexString
)
) {
SnodeAPI.deleteMessage(groupId.hexString, OwnedSwarmAuth.ofClosedGroup(groupId, adminKey), hashes)
.await()
SnodeAPI.deleteMessage(
groupId.hexString,
OwnedSwarmAuth.ofClosedGroup(groupId, adminKey),
hashes
).await()
}
// The non-admin user shouldn't be able to delete other user's messages so we will