diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 811ca8114e..0574042a5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -41,7 +41,8 @@ import com.squareup.phrase.Phrase; import org.conscrypt.Conscrypt; import org.session.libsession.database.MessageDataProvider; import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.session.libsession.messaging.configs.ConfigUploader; +import org.thoughtcrime.securesms.configs.ConfigToDatabaseSync; +import org.thoughtcrime.securesms.configs.ConfigUploader; import org.session.libsession.messaging.groups.GroupManagerV2; import org.session.libsession.messaging.groups.RemoveGroupMemberHandler; import org.session.libsession.messaging.notifications.TokenFetcher; @@ -160,8 +161,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Inject SSKEnvironment.ProfileManagerProtocol profileManager; CallMessageProcessor callMessageProcessor; MessagingModuleConfiguration messagingModuleConfiguration; - @Inject - ConfigUploader configUploader; + @Inject ConfigUploader configUploader; + @Inject ConfigToDatabaseSync configToDatabaseSync; @Inject RemoveGroupMemberHandler removeGroupMemberHandler; @Inject SnodeClock snodeClock; @@ -267,10 +268,11 @@ public class ApplicationContext extends Application implements DefaultLifecycleO NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create(); HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); + snodeClock.start(); pushRegistrationHandler.run(); configUploader.start(); + configToDatabaseSync.start(); removeGroupMemberHandler.start(); - snodeClock.start(); // add our shortcut debug menu if we are not in a release build if (BuildConfig.BUILD_TYPE != "release") { diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt new file mode 100644 index 0000000000..0232c5ed8d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -0,0 +1,325 @@ +package org.thoughtcrime.securesms.configs + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED +import network.loki.messenger.libsession_util.ReadableContacts +import network.loki.messenger.libsession_util.ReadableConversationVolatileConfig +import network.loki.messenger.libsession_util.ReadableGroupInfoConfig +import network.loki.messenger.libsession_util.ReadableUserGroupsConfig +import network.loki.messenger.libsession_util.ReadableUserProfile +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.UserPic +import network.loki.messenger.libsession_util.util.afterSend +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.jobs.BackgroundGroupAddJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.crypto.ecc.DjbECPrivateKey +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.crypto.ecc.ECKeyPair +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.PollerFactory +import org.thoughtcrime.securesms.groups.ClosedGroupManager +import org.thoughtcrime.securesms.groups.OpenGroupManager +import javax.inject.Inject + +private const val TAG = "ConfigToDatabaseSync" + +/** + * This class is responsible for syncing config system's data into the database. + * + * It does so by listening to the [ConfigFactoryProtocol.configUpdateNotifications] and updating the database accordingly. + * + * @see ConfigUploader For upload config system data into swarm automagically. + */ +class ConfigToDatabaseSync @Inject constructor( + @ApplicationContext private val context: Context, + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + private val threadDatabase: ThreadDatabase, + private val recipientDatabase: RecipientDatabase, + private val mmsDatabase: MmsDatabase, + private val pollerFactory: PollerFactory, + private val clock: SnodeClock, +) { + private var job: Job? = null + + fun start() { + require(job == null) { "Already started" } + + @Suppress("OPT_IN_USAGE") + job = GlobalScope.launch { + val groupMutex = hashMapOf() + val userMutex = Mutex() + + configFactory.configUpdateNotifications.collect { notification -> + when (notification) { + is ConfigUpdateNotification.UserConfigs -> { + launch { + userMutex.withLock { + syncUserConfigs() + } + } + } + + is ConfigUpdateNotification.GroupConfigsUpdated -> { + val groupId = notification.groupId + val mutex = groupMutex.getOrPut(groupId) { Mutex() } + + launch { + mutex.withLock { + syncGroupConfigs(groupId) + } + } + } + + is ConfigUpdateNotification.GroupConfigsDeleted -> { + groupMutex.remove(notification.groupId) + } + } + } + } + } + + private fun syncGroupConfigs(groupId: AccountId) { + configFactory.withGroupConfigs(groupId) { + updateGroup(it.groupInfo) + } + } + + private fun syncUserConfigs() { + val messageTimestamp = clock.currentTimeMills() + configFactory.withUserConfigs { configs -> + updateUser(configs.userProfile, messageTimestamp) + updateUserGroups(configs.userGroups, messageTimestamp) + updateContacts(configs.contacts, messageTimestamp) + updateConvoVolatile(configs.convoInfoVolatile) + } + } + + private fun updateUser(userProfile: ReadableUserProfile, messageTimestamp: Long) { + val userPublicKey = storage.getUserPublicKey() ?: return + // would love to get rid of recipient and context from this + val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) + + // Update profile name + val name = userProfile.getName() ?: return + val userPic = userProfile.getPic() + val profileManager = SSKEnvironment.shared.profileManager + + name.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let { + TextSecurePreferences.setProfileName(context, it) + profileManager.setName(context, recipient, it) + } + + // Update profile picture + if (userPic == UserPic.DEFAULT) { + storage.clearUserPic() + } else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty() + && TextSecurePreferences.getProfilePictureURL(context) != userPic.url + ) { + storage.setUserProfilePicture(userPic.url, userPic.key) + } + + if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) { + // delete nts thread if needed + val ourThread = storage.getThreadId(recipient) ?: return + storage.deleteConversation(ourThread) + } else { + // create note to self thread if needed (?) + val address = recipient.address + val ourThread = storage.getThreadId(address) ?: storage.getOrCreateThreadIdFor(address).also { + storage.setThreadDate(it, 0) + } + threadDatabase.setHasSent(ourThread, true) + storage.setPinned(ourThread, userProfile.getNtsPriority() > 0) + } + + // Set or reset the shared library to use latest expiration config + storage.getThreadId(recipient)?.let { + storage.setExpirationConfiguration( + storage.getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: + ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp) + ) + } + } + + private fun updateGroup(groupInfoConfig: ReadableGroupInfoConfig) { + val threadId = storage.getThreadId(fromSerialized(groupInfoConfig.id().hexString)) ?: return + val recipient = storage.getRecipientForThread(threadId) ?: return + recipientDatabase.setProfileName(recipient, groupInfoConfig.getName()) + groupInfoConfig.getDeleteBefore()?.let { removeBefore -> + storage.trimThreadBefore(threadId, removeBefore) + } + groupInfoConfig.getDeleteAttachmentsBefore()?.let { removeAttachmentsBefore -> + mmsDatabase.deleteMessagesInThreadBeforeDate(threadId, removeAttachmentsBefore, onlyMedia = true) + } + } + + private fun updateContacts(contacts: ReadableContacts, messageTimestamp: Long) { + val extracted = contacts.all().toList() + storage.addLibSessionContacts(extracted, messageTimestamp) + } + + private fun updateUserGroups(userGroups: ReadableUserGroupsConfig, messageTimestamp: Long) { + val localUserPublicKey = storage.getUserPublicKey() ?: return Log.w( + "Loki", + "No user public key when trying to update user groups from config" + ) + val communities = userGroups.allCommunityInfo() + val lgc = userGroups.allLegacyGroupInfo() + val allOpenGroups = storage.getAllOpenGroups() + val toDeleteCommunities = allOpenGroups.filter { + Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in communities.map { it.community.fullUrl() } + } + + val existingCommunities: Map = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys } + val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } } + val existingJoinUrls = existingCommunities.values.map { it.joinURL } + + val existingLegacyClosedGroups = storage.getAllGroups(includeInactive = true).filter { it.isLegacyClosedGroup } + val lgcIds = lgc.map { it.accountId } + val toDeleteClosedGroups = existingLegacyClosedGroups.filter { group -> + GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds + } + + // delete the ones which are not listed in the config + toDeleteCommunities.values.forEach { openGroup -> + OpenGroupManager.delete(openGroup.server, openGroup.room, context) + } + + toDeleteClosedGroups.forEach { deleteGroup -> + val threadId = storage.getThreadId(deleteGroup.encodedId) + if (threadId != null) { + ClosedGroupManager.silentlyRemoveGroup(context,threadId, + GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true) + } + } + + toAddCommunities.forEach { toAddCommunity -> + val joinUrl = toAddCommunity.community.fullUrl() + if (!storage.hasBackgroundGroupAddJob(joinUrl)) { + JobQueue.shared.add(BackgroundGroupAddJob(joinUrl)) + } + } + + for (groupInfo in communities) { + val groupBaseCommunity = groupInfo.community + if (groupBaseCommunity.fullUrl() in existingJoinUrls) { + // add it + val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() } + threadDatabase.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED) + } + } + + val newClosedGroups = userGroups.allClosedGroupInfo() + for (closedGroup in newClosedGroups) { + val recipient = Recipient.from(context, fromSerialized(closedGroup.groupAccountId.hexString), false) + storage.setRecipientApprovedMe(recipient, true) + storage.setRecipientApproved(recipient, !closedGroup.invited) + val threadId = storage.getOrCreateThreadIdFor(recipient.address) + storage.setPinned(threadId, closedGroup.priority == PRIORITY_PINNED) + if (!closedGroup.invited) { + pollerFactory.pollerFor(closedGroup.groupAccountId)?.start() + } + } + + for (group in lgc) { + val groupId = GroupUtil.doubleEncodeGroupID(group.accountId) + val existingGroup = existingLegacyClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId } + val existingThread = existingGroup?.let { storage.getThreadId(existingGroup.encodedId) } + if (existingGroup != null) { + if (group.priority == PRIORITY_HIDDEN && existingThread != null) { + ClosedGroupManager.silentlyRemoveGroup(context,existingThread, + GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true) + } else if (existingThread == null) { + Log.w("Loki-DBG", "Existing group had no thread to hide") + } else { + Log.d("Loki-DBG", "Setting existing group pinned status to ${group.priority}") + threadDatabase.setPinned(existingThread, group.priority == PRIORITY_PINNED) + } + } else { + val members = group.members.keys.map { fromSerialized(it) } + val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { fromSerialized(it) } + val title = group.name + val formationTimestamp = (group.joinedAt * 1000L) + storage.createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) + storage.setProfileSharing(fromSerialized(groupId), true) + // Add the group to the user's set of public keys to poll for + storage.addClosedGroupPublicKey(group.accountId) + // Store the encryption key pair + val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) + storage.addClosedGroupEncryptionKeyPair(keyPair, group.accountId, clock.currentTimeMills()) + // Notify the PN server + PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey) + // Notify the user + val threadID = storage.getOrCreateThreadIdFor(fromSerialized(groupId)) + threadDatabase.setDate(threadID, formationTimestamp) + + // Note: Commenting out this line prevents the timestamp of room creation being added to a new closed group, + // which in turn allows us to show the `groupNoMessages` control message text. + //insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) + + // Don't create config group here, it's from a config update + // Start polling + LegacyClosedGroupPollerV2.shared.startPolling(group.accountId) + } + storage.getThreadId(fromSerialized(groupId))?.let { + storage.setExpirationConfiguration( + storage.getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } + ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp) + ) + } + } + } + + private fun updateConvoVolatile(convos: ReadableConversationVolatileConfig) { + val extracted = convos.all().filterNotNull() + for (conversation in extracted) { + val threadId = when (conversation) { + is Conversation.OneToOne -> storage.getThreadIdFor(conversation.accountId, null, null, createThread = false) + is Conversation.LegacyGroup -> storage.getThreadIdFor("", conversation.groupId,null, createThread = false) + is Conversation.Community -> storage.getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) + is Conversation.ClosedGroup -> storage.getThreadIdFor(conversation.accountId, null, null, createThread = false) // New groups will be managed bia libsession + } + if (threadId != null) { + if (conversation.lastRead > storage.getLastSeen(threadId)) { + storage.markConversationAsRead(threadId, conversation.lastRead, force = true) + } + storage.updateThread(threadId, false) + } + } + } +} + +/** + * Truncate a string to a specified number of bytes + * + * This could split multi-byte characters/emojis. + */ +private fun String.truncate(sizeInBytes: Int): String = + toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this diff --git a/libsession/src/main/java/org/session/libsession/messaging/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt similarity index 96% rename from libsession/src/main/java/org/session/libsession/messaging/configs/ConfigUploader.kt rename to app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index a5f17ff3d8..e794bb7f1d 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -1,4 +1,4 @@ -package org.session.libsession.messaging.configs +package org.thoughtcrime.securesms.configs import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -36,6 +36,12 @@ private const val TAG = "ConfigUploader" /** * This class is responsible for sending the local config changes to the swarm. * + * Note: This class is listening ONLY to the config system changes. If you change any local database + * data, this class will not be aware of it. You'll need to update the config system + * for this class to pick up these changes. + * + * @see ConfigToDatabaseSync For syncing the config changes to the local database. + * * It does so by listening for changes in the config factory. */ class ConfigUploader @Inject constructor(