Clock management and kicked

This commit is contained in:
SessionHero01 2024-10-03 12:15:51 +10:00
parent 1f5fde0d9a
commit a5c89d8d5a
No known key found for this signature in database
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.notifications.MessageNotifier;
import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2; import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2;
import org.session.libsession.messaging.sending_receiving.pollers.Poller; 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.snode.SnodeModule;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Device; import org.session.libsession.utilities.Device;
@ -165,6 +166,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@Inject ConfigSyncHandler configSyncHandler; @Inject ConfigSyncHandler configSyncHandler;
@Inject RemoveGroupMemberHandler removeGroupMemberHandler; @Inject RemoveGroupMemberHandler removeGroupMemberHandler;
@Inject SnodeClock snodeClock;
private volatile boolean isAppVisible; private volatile boolean isAppVisible;
@ -236,7 +238,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
lastSentTimestampCache, lastSentTimestampCache,
this, this,
tokenFetcher, tokenFetcher,
groupManagerV2 groupManagerV2,
snodeClock
); );
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()");
@ -270,6 +273,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
pushRegistrationHandler.run(); pushRegistrationHandler.run();
configSyncHandler.start(); configSyncHandler.start();
removeGroupMemberHandler.start(); removeGroupMemberHandler.start();
snodeClock.start();
// add our shortcut debug menu if we are not in a release build // add our shortcut debug menu if we are not in a release build
if (BuildConfig.BUILD_TYPE != "release") { if (BuildConfig.BUILD_TYPE != "release") {

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -242,14 +243,13 @@ class EditGroupViewModel @AssistedInject constructor(
mutableInProgress.value = true mutableInProgress.value = true
// We need to use GlobalScope here because we don't want // We need to use GlobalScope here because we don't want
// "removeMember" to be cancelled when the view model is cleared. This operation // any group operation to be cancelled when the view model is cleared.
// is expected to complete even if the view model is cleared. val task = GlobalScope.async {
val task = GlobalScope.launch {
operation() operation()
} }
try { try {
task.join() task.await()
} catch (e: Exception) { } catch (e: Exception) {
mutableError.value = e.localizedMessage.orEmpty() mutableError.value = e.localizedMessage.orEmpty()
} finally { } finally {

View File

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

View File

@ -1,9 +1,11 @@
package network.loki.messenger.libsession_util.util package network.loki.messenger.libsession_util.util
import java.util.regex.Pattern
object Sodium { object Sodium {
const val KICKED_DOMAIN = "SessionGroupKickedMessage" const val KICKED_DOMAIN = "SessionGroupKickedMessage"
val KICKED_REGEX = Regex("05\\w{64}-\\d+") val KICKED_REGEX: Pattern = Pattern.compile("^(05[a-zA-Z0-9]{64})(\\d+)$")
init { init {
System.loadLibrary("session_util") System.loadLibrary("session_util")

View File

@ -7,6 +7,7 @@ import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2 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.snode.SnodeClock
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.Device import org.session.libsession.utilities.Device
import org.session.libsession.utilities.Toaster import org.session.libsession.utilities.Toaster
@ -22,6 +23,7 @@ class MessagingModuleConfiguration(
val toaster: Toaster, val toaster: Toaster,
val tokenFetcher: TokenFetcher, val tokenFetcher: TokenFetcher,
val groupManagerV2: GroupManagerV2, val groupManagerV2: GroupManagerV2,
val clock: SnodeClock,
) { ) {
companion object { companion object {

View File

@ -29,7 +29,21 @@ interface GroupManagerV2 {
removeMessages: Boolean removeMessages: Boolean
) )
suspend fun handleMemberLeft(message: GroupUpdated, closedGroupId: AccountId) /**
* Remove all messages from the group for the given members.
*
* This will delete all messages locally, and, if the user is an admin, remotely as well.
*
* Note: unlike [handleDeleteMemberContent], [requestMessageDeletion], this method
* does not try to validate the validity of the request, it also does not ask other members
* to delete the messages. It simply removes what it can.
*/
suspend fun removeMemberMessages(
groupAccountId: AccountId,
members: List<AccountId>
)
suspend fun handleMemberLeft(message: GroupUpdated, group: AccountId)
suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean) suspend fun leaveGroup(group: AccountId, deleteOnLeave: Boolean)
@ -59,8 +73,22 @@ interface GroupManagerV2 {
suspend fun setName(groupId: AccountId, newName: String) suspend fun setName(groupId: AccountId, newName: String)
/**
* Send a request to the group to delete the given messages.
*
* It can be called by a regular member who wishes to delete their own messages.
* It can also called by an admin, who can delete any messages from any member.
*/
suspend fun requestMessageDeletion(groupId: AccountId, messageHashes: List<String>) suspend fun requestMessageDeletion(groupId: AccountId, messageHashes: List<String>)
/**
* Handle a request to delete a member's content from the group. This is called when we receive
* a message from the server that a member's content needs to be deleted. (usually sent by
* [requestMessageDeletion], for example)
*
* In contrast to [removeMemberMessages], where it will remove the messages blindly, this method
* will check if the right conditions are met before removing the messages.
*/
suspend fun handleDeleteMemberContent( suspend fun handleDeleteMemberContent(
groupId: AccountId, groupId: AccountId,
deleteMemberContent: GroupUpdateDeleteMemberContentMessage, deleteMemberContent: GroupUpdateDeleteMemberContentMessage,

View File

@ -2,10 +2,8 @@ package org.session.libsession.messaging.groups
import android.os.SystemClock import android.os.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -23,6 +21,7 @@ import org.session.libsession.snode.SnodeMessage
import org.session.libsession.snode.utilities.await import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.waitUntilGroupConfigsPushed
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.AccountId
@ -35,17 +34,22 @@ private const val TAG = "RemoveGroupMemberHandler"
private const val MIN_PROCESS_INTERVAL_MILLS = 1_000L private const val MIN_PROCESS_INTERVAL_MILLS = 1_000L
/**
* This handler is responsible for processing pending group member removals.
*
* It automatically does so by listening to the config updates changes and checking for any pending removals.
*/
class RemoveGroupMemberHandler @Inject constructor( class RemoveGroupMemberHandler @Inject constructor(
private val configFactory: ConfigFactoryProtocol, private val configFactory: ConfigFactoryProtocol,
private val textSecurePreferences: TextSecurePreferences, private val textSecurePreferences: TextSecurePreferences,
private val groupManager: GroupManagerV2,
) { ) {
private val scope: CoroutineScope = GlobalScope
private var job: Job? = null private var job: Job? = null
fun start() { fun start() {
require(job == null) { "Already started" } require(job == null) { "Already started" }
job = scope.launch { job = GlobalScope.launch {
while (true) { while (true) {
// Make sure we have a local number before we start processing // Make sure we have a local number before we start processing
textSecurePreferences.watchLocalNumber().first { it != null } textSecurePreferences.watchLocalNumber().first { it != null }
@ -74,74 +78,54 @@ class RemoveGroupMemberHandler @Inject constructor(
} }
private suspend fun processPendingMemberRemoval() { private suspend fun processPendingMemberRemoval() {
// Run the removal process for each group in parallel configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() }
val removalTasks = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() }
.asSequence() .asSequence()
.filter { it.hasAdminKey() } .filter { it.hasAdminKey() }
.associate { group -> .forEach { group ->
group.name to scope.async { processPendingRemovalsForGroup(group.groupAccountId, group.adminKey!!)
processPendingRemovalsForGroup(
groupAccountId = group.groupAccountId,
groupName = group.name,
adminKey = group.adminKey!!
)
}
} }
// Wait and collect the results of the removal tasks
for ((groupName, task) in removalTasks) {
try {
task.await()
} catch (e: Exception) {
Log.e(TAG, "Error processing pending removals for group $groupName", e)
}
}
} }
private suspend fun processPendingRemovalsForGroup( private suspend fun processPendingRemovalsForGroup(
groupAccountId: AccountId, groupAccountId: AccountId,
groupName: String,
adminKey: ByteArray adminKey: ByteArray
) { ) {
val swarmAuth = OwnedSwarmAuth( val groupAuth = OwnedSwarmAuth.ofClosedGroup(groupAccountId, adminKey)
accountId = groupAccountId,
ed25519PublicKeyHex = null,
ed25519PrivateKey = adminKey
)
val batchCalls = configFactory.withGroupConfigs(groupAccountId) { configs -> val (pendingRemovals, batchCalls) = configFactory.withGroupConfigs(groupAccountId) { configs ->
val pendingRemovals = configs.groupMembers.all().filter { it.removed } val pendingRemovals = configs.groupMembers.all().filter { it.removed }
if (pendingRemovals.isEmpty()) { if (pendingRemovals.isEmpty()) {
// Skip if there are no pending removals // Skip if there are no pending removals
return@withGroupConfigs emptyList() return@withGroupConfigs pendingRemovals to emptyList()
} }
Log.d(TAG, "Processing ${pendingRemovals.size} pending removals for group $groupName") Log.d(TAG, "Processing ${pendingRemovals.size} pending removals for group")
// Perform a sequential call to group snode to: // Perform a sequential call to group snode to:
// 1. Revoke the member's sub key (by adding the key to a "revoked list" under the hood) // 1. Revoke the member's sub key (by adding the key to a "revoked list" under the hood)
// 2. Send a message to a special namespace to inform the removed members they have been removed // 2. Send a message to a special namespace on the group to inform the removed members they have been removed
// 3. Conditionally, delete removed-members' messages from the group's message store, if that option is selected by the actioning admin // 3. Conditionally, send a `GroupUpdateDeleteMemberContent` to the group so the message deletion
// can be performed by everyone in the group.
val calls = ArrayList<SnodeAPI.SnodeBatchRequestInfo>(3) val calls = ArrayList<SnodeAPI.SnodeBatchRequestInfo>(3)
// Call No 1. Revoke sub-key. This call is crucial and must not fail for the rest of the operation to be successful. // Call No 1. Revoke sub-key. This call is crucial and must not fail for the rest of the operation to be successful.
calls += checkNotNull( calls += checkNotNull(
SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest( SnodeAPI.buildAuthenticatedRevokeSubKeyBatchRequest(
groupAdminAuth = swarmAuth, groupAdminAuth = groupAuth,
subAccountTokens = pendingRemovals.map { subAccountTokens = pendingRemovals.map {
configs.groupKeys.getSubAccountToken(AccountId(it.sessionId)) configs.groupKeys.getSubAccountToken(AccountId(it.sessionId))
} }
) )
) { "Fail to create a revoke request" } ) { "Fail to create a revoke request" }
// Call No 2. Send a message to the removed members // Call No 2. Send a "kicked" message to the revoked namespace
calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( calls += SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.REVOKED_GROUP_MESSAGES(), namespace = Namespace.REVOKED_GROUP_MESSAGES(),
message = buildGroupKickMessage(groupAccountId.hexString, pendingRemovals, configs.groupKeys, adminKey), message = buildGroupKickMessage(groupAccountId.hexString, pendingRemovals, configs.groupKeys, adminKey),
auth = swarmAuth, auth = groupAuth,
) )
// Call No 3. Conditionally remove the message from the group's message store // Call No 3. Conditionally send the `GroupUpdateDeleteMemberContent`
if (pendingRemovals.any { it.shouldRemoveMessages }) { if (pendingRemovals.any { it.shouldRemoveMessages }) {
calls += SnodeAPI.buildAuthenticatedStoreBatchInfo( calls += SnodeAPI.buildAuthenticatedStoreBatchInfo(
namespace = Namespace.CLOSED_GROUP_MESSAGES(), namespace = Namespace.CLOSED_GROUP_MESSAGES(),
@ -152,11 +136,11 @@ class RemoveGroupMemberHandler @Inject constructor(
.filter { it.shouldRemoveMessages } .filter { it.shouldRemoveMessages }
.map { it.sessionId } .map { it.sessionId }
), ),
auth = swarmAuth, auth = groupAuth,
) )
} }
calls pendingRemovals to (calls as List<SnodeAPI.SnodeBatchRequestInfo>)
} }
if (batchCalls.isEmpty()) { if (batchCalls.isEmpty()) {
@ -164,9 +148,39 @@ class RemoveGroupMemberHandler @Inject constructor(
} }
val node = SnodeAPI.getSingleTargetSnode(groupAccountId.hexString).await() val node = SnodeAPI.getSingleTargetSnode(groupAccountId.hexString).await()
SnodeAPI.getBatchResponse(node, groupAccountId.hexString, batchCalls, true) val response = SnodeAPI.getBatchResponse(node, groupAccountId.hexString, batchCalls, sequence = true)
//TODO: Handle message removal val firstError = response.results.firstOrNull { !it.isSuccessful }
check(firstError == null) {
"Error processing pending removals for group: code = ${firstError?.code}, body = ${firstError?.body}"
}
Log.d(TAG, "Essential steps for group removal are done")
// The essential part of the operation has been successful once we get to this point,
// now we can go ahead and update the configs
configFactory.withMutableGroupConfigs(groupAccountId) { configs ->
pendingRemovals.forEach(configs.groupMembers::erase)
configs.rekey()
}
configFactory.waitUntilGroupConfigsPushed(groupAccountId)
Log.d(TAG, "Group configs updated")
// Try to delete members' message. It's ok to fail as they will be re-tried in different
// cases (a.k.a the GroupUpdateDeleteMemberContent message handling) and could be by different admins.
val deletingMessagesForMembers = pendingRemovals.filter { it.shouldRemoveMessages }
if (deletingMessagesForMembers.isNotEmpty()) {
try {
groupManager.removeMemberMessages(
groupAccountId,
deletingMessagesForMembers.map { AccountId(it.sessionId) }
)
} catch (e: Exception) {
Log.e(TAG, "Error deleting messages for removed members", e)
}
}
} }
private fun buildDeleteGroupMemberContentMessage( private fun buildDeleteGroupMemberContentMessage(
@ -211,7 +225,7 @@ class RemoveGroupMemberHandler @Inject constructor(
domain = Sodium.KICKED_DOMAIN domain = Sodium.KICKED_DOMAIN
) )
), ),
ttl = SnodeMessage.CONFIG_TTL, ttl = SnodeMessage.DEFAULT_TTL,
timestamp = SnodeAPI.nowWithOffset timestamp = SnodeAPI.nowWithOffset
) )
} }

View File

@ -5,6 +5,7 @@ import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate
import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.snode.SnodeMessage
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType
@ -25,7 +26,7 @@ abstract class Message {
open val coerceDisappearAfterSendToRead = false open val coerceDisappearAfterSendToRead = false
open val defaultTtl: Long = 14 * 24 * 60 * 60 * 1000 open val defaultTtl: Long = SnodeMessage.DEFAULT_TTL
open val ttl: Long get() = specifiedTtl ?: defaultTtl open val ttl: Long get() = specifiedTtl ?: defaultTtl
open val isSelfSendValid: Boolean = false open val isSelfSendValid: Boolean = false

View File

@ -55,6 +55,7 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMemberChangeMessage
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64

View File

@ -5,10 +5,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import network.loki.messenger.libsession_util.util.Sodium import network.loki.messenger.libsession_util.util.Sodium
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.GroupManagerV2
@ -22,7 +22,6 @@ import org.session.libsession.snode.model.RetrieveMessageResponse
import org.session.libsession.snode.utilities.await import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.ConfigMessage
import org.session.libsession.utilities.getClosedGroup
import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.AccountId
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
@ -102,9 +101,9 @@ class ClosedGroupPoller(
job = null job = null
} }
private suspend fun poll(snode: Snode): Unit = coroutineScope { private suspend fun poll(snode: Snode): Unit = supervisorScope {
val groupAuth = val groupAuth =
configFactoryProtocol.getGroupAuth(closedGroupSessionId) ?: return@coroutineScope configFactoryProtocol.getGroupAuth(closedGroupSessionId) ?: return@supervisorScope
val configHashesToExtends = configFactoryProtocol.withGroupConfigs(closedGroupSessionId) { val configHashesToExtends = configFactoryProtocol.withGroupConfigs(closedGroupSessionId) {
buildSet { buildSet {
addAll(it.groupKeys.currentHashes()) addAll(it.groupKeys.currentHashes())
@ -121,23 +120,21 @@ class ClosedGroupPoller(
val pollingTasks = mutableListOf<Pair<String, Deferred<*>>>() val pollingTasks = mutableListOf<Pair<String, Deferred<*>>>()
pollingTasks += "retrieving revoked messages" to async { val receiveRevokeMessage = async {
handleRevoked( SnodeAPI.sendBatchRequest(
SnodeAPI.sendBatchRequest( snode,
snode, closedGroupSessionId.hexString,
closedGroupSessionId.hexString, SnodeAPI.buildAuthenticatedRetrieveBatchRequest(
SnodeAPI.buildAuthenticatedRetrieveBatchRequest( lastHash = lokiApiDatabase.getLastMessageHashValue(
lastHash = lokiApiDatabase.getLastMessageHashValue( snode,
snode, closedGroupSessionId.hexString,
closedGroupSessionId.hexString, Namespace.REVOKED_GROUP_MESSAGES()
Namespace.REVOKED_GROUP_MESSAGES() ).orEmpty(),
).orEmpty(), auth = groupAuth,
auth = groupAuth, namespace = Namespace.REVOKED_GROUP_MESSAGES(),
namespace = Namespace.REVOKED_GROUP_MESSAGES(), maxSize = null,
maxSize = null, ),
), RetrieveMessageResponse::class.java
RetrieveMessageResponse::class.java
)
) )
} }
@ -198,18 +195,28 @@ class ClosedGroupPoller(
} }
} }
// The retrieval of the config and regular messages can be done concurrently, // The retrieval of the all group messages can be done concurrently,
// however, in order for the messages to be able to be decrypted, the config messages // however, in order for the messages to be able to be decrypted, the config messages
// must be processed first. // must be processed first.
pollingTasks += "polling and handling group config keys and messages" to async { pollingTasks += "polling and handling group config keys and messages" to async {
val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() } val result = runCatching {
saveLastMessageHash(snode, keysMessage, Namespace.ENCRYPTION_KEYS()) val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() }
saveLastMessageHash(snode, infoMessage, Namespace.CLOSED_GROUP_INFO()) handleGroupConfigMessages(keysMessage, infoMessage, membersMessage)
saveLastMessageHash(snode, membersMessage, Namespace.CLOSED_GROUP_MEMBERS()) saveLastMessageHash(snode, keysMessage, Namespace.ENCRYPTION_KEYS())
handleGroupConfigMessages(keysMessage, infoMessage, membersMessage) saveLastMessageHash(snode, infoMessage, Namespace.CLOSED_GROUP_INFO())
saveLastMessageHash(snode, membersMessage, Namespace.CLOSED_GROUP_MEMBERS())
val regularMessages = groupMessageRetrieval.await() val regularMessages = groupMessageRetrieval.await()
handleMessages(regularMessages, snode) handleMessages(regularMessages, snode)
}
// Revoke message must be handled regardless, and at the end
val revokedMessages = receiveRevokeMessage.await()
handleRevoked(revokedMessages)
saveLastMessageHash(snode, revokedMessages, Namespace.REVOKED_GROUP_MESSAGES())
// Propagate any prior exceptions
result.getOrThrow()
} }
// Wait for all tasks to complete, gather any exceptions happened during polling // Wait for all tasks to complete, gather any exceptions happened during polling
@ -254,23 +261,23 @@ class ClosedGroupPoller(
) )
if (decoded != null) { if (decoded != null) {
Log.d(TAG, "decoded kick message was for us")
val message = decoded.decodeToString() val message = decoded.decodeToString()
if (Sodium.KICKED_REGEX.matches(message)) { val matcher = Sodium.KICKED_REGEX.matcher(message)
val (sessionId, generation) = message.split("-") if (matcher.matches()) {
val currentKeysGeneration by lazy { val sessionId = matcher.group(1)
configFactoryProtocol.withGroupConfigs(closedGroupSessionId) { val messageGeneration = matcher.group(2)!!.toInt()
it.groupKeys.currentGeneration() val currentKeysGeneration = configFactoryProtocol.withGroupConfigs(closedGroupSessionId) {
} it.groupKeys.currentGeneration()
} }
if (sessionId == storage.getUserPublicKey() && generation.toInt() >= currentKeysGeneration) { val isForMe = sessionId == storage.getUserPublicKey()
try { Log.d(TAG, "Received kicked message, for us? ${sessionId == storage.getUserPublicKey()}, message key generation = $messageGeneration, our key generation = $currentKeysGeneration")
groupManagerV2.handleKicked(closedGroupSessionId)
} catch (e: Exception) { if (isForMe && messageGeneration >= currentKeysGeneration) {
Log.e("GroupPoller", "Error handling kicked message: $e") groupManagerV2.handleKicked(closedGroupSessionId)
}
} }
} else {
Log.w(TAG, "Received an invalid kicked message")
} }
} }
} }

View File

@ -1,6 +1,5 @@
package org.session.libsession.snode package org.session.libsession.snode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nl.komponents.kovenant.Deferred import nl.komponents.kovenant.Deferred
@ -26,12 +25,10 @@ import org.session.libsignal.utilities.HTTP
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.Snode
import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.recover import org.session.libsignal.utilities.recover
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.collections.set import kotlin.collections.set
import kotlin.coroutines.EmptyCoroutineContext
private typealias Path = List<Snode> private typealias Path = List<Snode>
@ -603,11 +600,7 @@ object OnionRequestAPI {
val bodyAsString = json["body"] as String val bodyAsString = json["body"] as String
JsonUtil.fromJson(bodyAsString, Map::class.java) JsonUtil.fromJson(bodyAsString, Map::class.java)
} }
if (body["t"] != null) {
val timestamp = body["t"] as Long
val offset = timestamp - System.currentTimeMillis()
SnodeAPI.clockOffset = offset
}
if (body.containsKey("hf")) { if (body.containsKey("hf")) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val currentHf = body["hf"] as List<Int> val currentHf = body["hf"] as List<Int>

View File

@ -25,6 +25,7 @@ import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.functional.map
import nl.komponents.kovenant.task import nl.komponents.kovenant.task
import nl.komponents.kovenant.unwrap import nl.komponents.kovenant.unwrap
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.MessageWrapper
import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.BatchResponse
@ -47,6 +48,7 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.Snode
import org.session.libsignal.utilities.prettifiedDescription import org.session.libsignal.utilities.prettifiedDescription
import org.session.libsignal.utilities.retryIfNeeded import org.session.libsignal.utilities.retryIfNeeded
import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.collections.component1 import kotlin.collections.component1
import kotlin.collections.component2 import kotlin.collections.component2
@ -63,15 +65,11 @@ object SnodeAPI {
internal var snodePool: Set<Snode> internal var snodePool: Set<Snode>
get() = database.getSnodePool() get() = database.getSnodePool()
set(newValue) { database.setSnodePool(newValue) } set(newValue) { database.setSnodePool(newValue) }
/**
* The offset between the user's clock and the Service Node's clock. Used in cases where the
* user's clock is incorrect.
*/
internal var clockOffset = 0L
@Deprecated("Use a dependency injected SnodeClock.currentTimeMills() instead")
@JvmStatic @JvmStatic
val nowWithOffset val nowWithOffset
get() = System.currentTimeMillis() + clockOffset get() = MessagingModuleConfiguration.shared.clock.currentTimeMills()
internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue -> internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue ->
if (newValue > oldValue) { if (newValue > oldValue) {
@ -418,7 +416,6 @@ object SnodeAPI {
namespace = namespace, namespace = namespace,
auth = auth, auth = auth,
verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" },
timestamp = message.timestamp
) { ) {
putAll(message.toJSON()) putAll(message.toJSON())
} }
@ -785,7 +782,7 @@ object SnodeAPI {
parseRawMessagesResponse(resp, snode, auth.accountId.hexString) parseRawMessagesResponse(resp, snode, auth.accountId.hexString)
} }
private fun getNetworkTime(snode: Snode): Promise<Pair<Snode, Long>, Exception> = fun getNetworkTime(snode: Snode): Promise<Pair<Snode, Long>, Exception> =
invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse ->
val timestamp = rawResponse["timestamp"] as? Long ?: -1 val timestamp = rawResponse["timestamp"] as? Long ?: -1
snode to timestamp snode to timestamp
@ -805,13 +802,15 @@ object SnodeAPI {
"Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}" "Message sent to ${message.recipient} but authenticated with ${auth.accountId.hexString}"
} }
val timestamp = nowWithOffset
buildAuthenticatedParameters( buildAuthenticatedParameters(
auth = auth, auth = auth,
namespace = namespace, namespace = namespace,
verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" }, verificationData = { ns, t -> "${Snode.Method.SendMessage.rawValue}$ns$t" },
timestamp = message.timestamp timestamp = timestamp
) { ) {
put("sig_timestamp", message.timestamp) put("sig_timestamp", timestamp)
putAll(message.toJSON()) putAll(message.toJSON())
} }
} else { } else {
@ -921,7 +920,7 @@ object SnodeAPI {
fun deleteAllMessages(auth: SwarmAuth): Promise<Map<String, Boolean>, Exception> = fun deleteAllMessages(auth: SwarmAuth): Promise<Map<String, Boolean>, Exception> =
scope.retrySuspendAsPromise(maxRetryCount) { scope.retrySuspendAsPromise(maxRetryCount) {
val snode = getSingleTargetSnode(auth.accountId.hexString).await() val snode = getSingleTargetSnode(auth.accountId.hexString).await()
val (_, timestamp) = getNetworkTime(snode).await() val timestamp = MessagingModuleConfiguration.shared.clock.waitForNetworkAdjustedTime()
val params = buildAuthenticatedParameters( val params = buildAuthenticatedParameters(
auth = auth, auth = auth,

View File

@ -0,0 +1,88 @@
package org.session.libsession.snode
import android.os.SystemClock
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.session.libsession.snode.utilities.await
import org.session.libsignal.utilities.Log
import java.util.Date
/**
* A class that manages the network time by querying the network time from a random snode. The
* primary goal of this class is to provide a time that is not tied to current system time and not
* prone to time changes locally.
*
* Before the first network query is successfully, calling [currentTimeMills] will return the current
* system time.
*/
class SnodeClock() {
private val instantState = MutableStateFlow<Instant?>(null)
private var job: Job? = null
fun start() {
require(job == null) { "Already started" }
job = GlobalScope.launch {
while (true) {
try {
val node = SnodeAPI.getRandomSnode().await()
val requestStarted = SystemClock.uptimeMillis()
var networkTime = SnodeAPI.getNetworkTime(node).await().second
val requestEnded = SystemClock.uptimeMillis()
// Adjust the network time to account for the time it took to make the request
// so that the network time equals to the time when the request was started
networkTime -= (requestEnded - requestStarted) / 2
val inst = Instant(requestStarted, networkTime)
Log.d("SnodeClock", "Network time: ${Date(inst.now())}, system time: ${Date()}")
instantState.value = inst
} catch (e: Exception) {
Log.e("SnodeClock", "Failed to get network time. Retrying in a few seconds", e)
} finally {
// Retry frequently if we haven't got any result before
val delayMills = if (instantState.value == null) {
3_000L
} else {
3600_000L
}
delay(delayMills)
}
}
}
}
/**
* Wait for the network adjusted time to come through.
*/
suspend fun waitForNetworkAdjustedTime(): Long {
return instantState.filterNotNull().first().now()
}
/**
* Get the current time in milliseconds. If the network time is not available yet, this method
* will return the current system time.
*/
fun currentTimeMills(): Long {
return instantState.value?.now() ?: System.currentTimeMillis()
}
private class Instant(
val systemUptime: Long,
val networkTime: Long,
) {
fun now(): Long {
val elapsed = SystemClock.uptimeMillis() - systemUptime
return networkTime + elapsed
}
}
}

View File

@ -32,6 +32,7 @@ data class SnodeMessage(
} }
companion object { companion object {
const val CONFIG_TTL: Long = 30 * 24 * 60 * 60 * 1000L const val CONFIG_TTL: Long = 30 * 24 * 60 * 60 * 1000L // 30 days
const val DEFAULT_TTL: Long = 14 * 24 * 60 * 60 * 1000L // 14 days
} }
} }

View File

@ -1,6 +1,11 @@
package org.session.libsession.utilities package org.session.libsession.utilities
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withTimeoutOrNull
import network.loki.messenger.libsession_util.MutableConfig import network.loki.messenger.libsession_util.MutableConfig
import network.loki.messenger.libsession_util.MutableContacts import network.loki.messenger.libsession_util.MutableContacts
import network.loki.messenger.libsession_util.MutableConversationVolatileConfig import network.loki.messenger.libsession_util.MutableConversationVolatileConfig
@ -94,6 +99,52 @@ fun ConfigFactoryProtocol.getClosedGroup(groupId: AccountId): GroupInfo.ClosedGr
return withUserConfigs { it.userGroups.getClosedGroup(groupId.hexString) } return withUserConfigs { it.userGroups.getClosedGroup(groupId.hexString) }
} }
/**
* Wait until all user configs are pushed to the server.
*
* This function is not essential to the pushing of the configs, the config push will schedule
* itself upon changes, so this function is purely observatory.
*
* This function will check the user configs immediately, if nothing needs to be pushed, it will return immediately.
*
* @return True if all user configs are pushed, false if the timeout is reached.
*/
suspend fun ConfigFactoryProtocol.waitUntilUserConfigsPushed(timeoutMills: Long = 10_000L): Boolean {
fun needsPush() = withUserConfigs { configs ->
UserConfigType.entries.any { configs.getConfig(it).needsPush() }
}
return withTimeoutOrNull(timeoutMills){
configUpdateNotifications
.onStart { emit(ConfigUpdateNotification.UserConfigs) } // Trigger the filtering immediately
.filter { it == ConfigUpdateNotification.UserConfigs && !needsPush() }
.first()
} != null
}
/**
* Wait until all configs of given group are pushed to the server.
*
* This function is not essential to the pushing of the configs, the config push will schedule
* itself upon changes, so this function is purely observatory.
*
* This function will check the group configs immediately, if nothing needs to be pushed, it will return immediately.
*
* @return True if all group configs are pushed, false if the timeout is reached.
*/
suspend fun ConfigFactoryProtocol.waitUntilGroupConfigsPushed(groupId: AccountId, timeoutMills: Long = 10_000L): Boolean {
fun needsPush() = withGroupConfigs(groupId) { configs ->
configs.groupInfo.needsPush() || configs.groupMembers.needsPush()
}
return withTimeoutOrNull(timeoutMills) {
configUpdateNotifications
.onStart { emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId)) } // Trigger the filtering immediately
.filter { it == ConfigUpdateNotification.GroupConfigsUpdated(groupId) && !needsPush() }
.first()
} != null
}
interface UserConfigs { interface UserConfigs {
val contacts: ReadableContacts val contacts: ReadableContacts
val userGroups: ReadableUserGroupsConfig val userGroups: ReadableUserGroupsConfig