mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-25 11:05:25 +00:00
Clock management and kicked
This commit is contained in:
parent
1f5fde0d9a
commit
a5c89d8d5a
@ -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") {
|
||||||
|
@ -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) {
|
||||||
|
@ -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()
|
||||||
}
|
}
|
@ -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 {
|
||||||
|
@ -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,14 +397,12 @@ 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 promotionDeferred = members.associateWith { member ->
|
val promoteMessage = GroupUpdated(
|
||||||
async {
|
|
||||||
val message = GroupUpdated(
|
|
||||||
GroupUpdateMessage.newBuilder()
|
GroupUpdateMessage.newBuilder()
|
||||||
.setPromoteMessage(
|
.setPromoteMessage(
|
||||||
DataMessage.GroupUpdatePromoteMessage.newBuilder()
|
DataMessage.GroupUpdatePromoteMessage.newBuilder()
|
||||||
@ -376,8 +412,10 @@ class GroupManagerV2Impl @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val promotionDeferred = members.associateWith { member ->
|
||||||
|
async {
|
||||||
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)
|
// 2. Send a member change message
|
||||||
}
|
if (sendMemberChangeMessage) {
|
||||||
|
|
||||||
SnodeAPI.sendBatchRequest(
|
|
||||||
snode, group.hexString, SnodeAPI.buildAuthenticatedDeleteBatchInfo(
|
|
||||||
groupAuth,
|
|
||||||
messagesToDelete
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sendRemovedMessage) {
|
|
||||||
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
|
||||||
|
@ -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")
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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,8 +120,7 @@ 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,
|
||||||
@ -138,7 +136,6 @@ class ClosedGroupPoller(
|
|||||||
),
|
),
|
||||||
RetrieveMessageResponse::class.java
|
RetrieveMessageResponse::class.java
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configHashesToExtends.isNotEmpty() && adminKey != null) {
|
if (configHashesToExtends.isNotEmpty() && adminKey != null) {
|
||||||
@ -198,20 +195,30 @@ 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 result = runCatching {
|
||||||
val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() }
|
val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() }
|
||||||
|
handleGroupConfigMessages(keysMessage, infoMessage, membersMessage)
|
||||||
saveLastMessageHash(snode, keysMessage, Namespace.ENCRYPTION_KEYS())
|
saveLastMessageHash(snode, keysMessage, Namespace.ENCRYPTION_KEYS())
|
||||||
saveLastMessageHash(snode, infoMessage, Namespace.CLOSED_GROUP_INFO())
|
saveLastMessageHash(snode, infoMessage, Namespace.CLOSED_GROUP_INFO())
|
||||||
saveLastMessageHash(snode, membersMessage, Namespace.CLOSED_GROUP_MEMBERS())
|
saveLastMessageHash(snode, membersMessage, Namespace.CLOSED_GROUP_MEMBERS())
|
||||||
handleGroupConfigMessages(keysMessage, infoMessage, membersMessage)
|
|
||||||
|
|
||||||
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
|
||||||
val errors = pollingTasks.mapNotNull { (name, task) ->
|
val errors = pollingTasks.mapNotNull { (name, task) ->
|
||||||
runCatching { task.await() }
|
runCatching { task.await() }
|
||||||
@ -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()
|
||||||
|
val currentKeysGeneration = configFactoryProtocol.withGroupConfigs(closedGroupSessionId) {
|
||||||
it.groupKeys.currentGeneration()
|
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")
|
||||||
|
|
||||||
|
if (isForMe && messageGeneration >= currentKeysGeneration) {
|
||||||
groupManagerV2.handleKicked(closedGroupSessionId)
|
groupManagerV2.handleKicked(closedGroupSessionId)
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("GroupPoller", "Error handling kicked message: $e")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Received an invalid kicked message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user