From a0a42dee98777adbfa5792ba52d66dbc9922ec7b Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:17:52 +1100 Subject: [PATCH 1/2] Remove the need for stateflow for conversation list --- .../home/search/GlobalSearchViewModel.kt | 15 +- .../repository/ConversationRepository.kt | 547 +---------------- .../DefaultConversationRepository.kt | 566 ++++++++++++++++++ .../securesms/search/SearchRepository.kt | 63 +- 4 files changed, 602 insertions(+), 589 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index fa3f8c8726..bae01dc62d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -21,10 +22,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.search.SearchRepository -import org.thoughtcrime.securesms.search.model.SearchResult import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -45,9 +43,9 @@ class GlobalSearchViewModel @Inject constructor( configFactory.configUpdateNotifications ) - val noteToSelfString by lazy { application.getString(R.string.noteToSelf).lowercase() } + val noteToSelfString: String by lazy { application.getString(R.string.noteToSelf).lowercase() } - val result = combine( + val result: SharedFlow = combine( _queryText, observeChangesAffectingSearch().onStart { emit(Unit) } ) { query, _ -> query } @@ -64,7 +62,7 @@ class GlobalSearchViewModel @Inject constructor( ) } } else { - val results = searchRepository.suspendQuery(query).toGlobalSearchResult() + val results = searchRepository.query(query).toGlobalSearchResult() // show "Note to Self" is the user searches for parts of"Note to Self" if(noteToSelfString.contains(query.lowercase())){ @@ -85,8 +83,3 @@ class GlobalSearchViewModel @Inject constructor( } } -private suspend fun SearchRepository.suspendQuery(query: String): SearchResult { - return suspendCoroutine { cont -> - query(query, cont::resume) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 0e87c37208..f9ab5d885a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,68 +1,12 @@ package org.thoughtcrime.securesms.repository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext -import network.loki.messenger.libsession_util.util.ExpiryMode -import network.loki.messenger.libsession_util.util.GroupInfo -import org.session.libsession.database.MessageDataProvider -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.messages.MarkAsDeletedMessage -import org.session.libsession.messaging.messages.control.MessageRequestResponse -import org.session.libsession.messaging.messages.control.UnsendRequest -import org.session.libsession.messaging.messages.signal.OutgoingTextMessage -import org.session.libsession.messaging.messages.visible.OpenGroupInvitation -import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.sending_receiving.MessageSender -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.UserConfigType -import org.session.libsession.utilities.isGroupV2 -import org.session.libsession.utilities.isLegacyGroup -import org.session.libsession.utilities.isStandard import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.recipients.RecipientData -import org.session.libsession.utilities.upsertContact -import org.session.libsession.utilities.userConfigsChanged import org.session.libsignal.utilities.AccountId -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.auth.LoginStateRepository -import org.thoughtcrime.securesms.database.CommunityDatabase -import org.thoughtcrime.securesms.database.DraftDatabase -import org.thoughtcrime.securesms.database.LokiMessageDatabase -import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.database.RecipientSettingsDatabase -import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.util.castAwayType -import java.util.EnumSet -import javax.inject.Inject -import javax.inject.Singleton interface ConversationRepository { fun observeConversationList(): Flow> @@ -75,7 +19,7 @@ interface ConversationRepository { fun getConversationList(): List - val conversationListAddressesFlow: StateFlow> + val conversationListAddressesFlow: Flow> fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? @@ -126,492 +70,3 @@ interface ConversationRepository { */ suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int } - -@Singleton -class DefaultConversationRepository @Inject constructor( - private val messageDataProvider: MessageDataProvider, - private val threadDb: ThreadDatabase, - private val communityDatabase: CommunityDatabase, - private val draftDb: DraftDatabase, - private val smsDb: SmsDatabase, - private val mmsSmsDb: MmsSmsDatabase, - private val storage: Storage, - private val lokiMessageDb: LokiMessageDatabase, - private val configFactory: ConfigFactory, - private val groupManager: GroupManagerV2, - private val clock: SnodeClock, - private val recipientDatabase: RecipientSettingsDatabase, - private val recipientRepository: RecipientRepository, - @param:ManagerScope private val scope: CoroutineScope, - private val messageSender: MessageSender, - private val loginStateRepository: LoginStateRepository, - private val proStatusManager: ProStatusManager, -) : ConversationRepository { - - override val conversationListAddressesFlow = loginStateRepository.flowWithLoggedInState { - configFactory - .userConfigsChanged(EnumSet.of( - UserConfigType.CONTACTS, - UserConfigType.USER_PROFILE, - UserConfigType.USER_GROUPS - )) - .castAwayType() - .onStart { - emit(Unit) - } - .map { getConversationListAddresses() } - }.stateIn(scope, SharingStarted.Eagerly, getConversationListAddresses()) - - private fun getConversationListAddresses() = buildSet { - val myAddress = loginStateRepository.getLocalNumber()?.toAddress() as? Address.Standard - ?: return@buildSet - - // Always have NTS - we should only "hide" them on home screen - the convo should never be deleted - add(myAddress) - - configFactory.withUserConfigs { configs -> - // Contacts - for (contact in configs.contacts.all()) { - if (contact.priority >= 0 && (!contact.blocked || contact.approved)) { - add(Address.Standard(AccountId(contact.id))) - } - } - - // Blinded Contacts - for (blindedContact in configs.contacts.allBlinded()) { - if (blindedContact.priority >= 0) { - add(Address.CommunityBlindedId( - serverUrl = blindedContact.communityServer, - blindedId = Address.Blinded(AccountId(blindedContact.id)) - )) - } - } - - // Groups - for (group in configs.userGroups.all()) { - when (group) { - is GroupInfo.ClosedGroupInfo -> { - add(Address.Group(AccountId(group.groupAccountId))) - } - - is GroupInfo.LegacyGroupInfo -> { - add(Address.LegacyGroup(group.accountId)) - } - - is GroupInfo.CommunityGroupInfo -> { - add(Address.Community( - serverUrl = group.community.baseUrl, - room = group.community.room - )) - } - } - } - } - } - - - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - override fun observeConversationList(): Flow> { - return conversationListAddressesFlow - .flatMapLatest { allAddresses -> - merge( - configFactory.configUpdateNotifications, - recipientDatabase.changeNotification.filter { it in allAddresses }, - communityDatabase.changeNotification.filter { it in allAddresses }, - threadDb.updateNotifications, - // If pro status pref changes, the convo is likely needing changes too - TextSecurePreferences.events.filter { - it == TextSecurePreferences.SET_FORCE_OTHER_USERS_PRO || - it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO - it == TextSecurePreferences.SET_FORCE_POST_PRO - } - ).debounce(500) - .onStart { emit(allAddresses) } - .map { allAddresses } - } - .map { addresses -> - withContext(Dispatchers.Default) { - threadDb.getThreads(addresses) - } - } - } - - override fun getConversationList(): List { - return threadDb.getThreads(getConversationListAddresses()) - } - - override fun saveDraft(threadId: Long, text: String) { - if (text.isEmpty()) return - val drafts = DraftDatabase.Drafts() - drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text)) - draftDb.insertDrafts(threadId, drafts) - } - - override fun getDraft(threadId: Long): String? { - val drafts = draftDb.getDrafts(threadId) - return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value - } - - override fun clearDrafts(threadId: Long) { - draftDb.clearDrafts(threadId) - } - - override fun inviteContactsToCommunity( - communityRecipient: Recipient, - contacts: Collection
- ) { - val community = communityRecipient.data as? RecipientData.Community - val info = community?.roomInfo ?: return - for (contact in contacts) { - val message = VisibleMessage() - message.sentTimestamp = clock.currentTimeMills() - val openGroupInvitation = OpenGroupInvitation().apply { - name = info.details.name - url = community.joinURL - } - message.openGroupInvitation = openGroupInvitation - proStatusManager.addProFeatures(message) - val contactThreadId = threadDb.getOrCreateThreadIdFor(contact) - val expirationConfig = recipientRepository.getRecipientSync(contact).expiryMode - val expireStartedAt = if (expirationConfig is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 - val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( - openGroupInvitation, - contact, - message.sentTimestamp!!, - expirationConfig.expiryMillis, - expireStartedAt, - proFeatures = message.proFeatures - )!! - - message.id = MessageId( - smsDb.insertMessageOutbox(contactThreadId, outgoingTextMessage, false, message.sentTimestamp!!, true), - false - ) - - messageSender.send(message, contact) - } - } - - override fun isGroupReadOnly(recipient: Recipient): Boolean { - // We only care about group v2 recipient - if (!recipient.isGroupV2Recipient) { - return false - } - - val groupId = recipient.address.toString() - return configFactory.withUserConfigs { configs -> - configs.userGroups.getClosedGroup(groupId)?.let { it.kicked || it.destroyed } == true - } - } - - override fun getLastSentMessageID(threadId: Long): Flow { - return (threadDb.updateNotifications.filter { it == threadId } as Flow<*>) - .onStart { emit(Unit) } - .map { - withContext(Dispatchers.Default) { - mmsSmsDb.getLastSentMessageID(threadId) - } - } - } - - // This assumes that recipient.isContactRecipient is true - override fun setBlocked(recipient: Address, blocked: Boolean) { - if (recipient.isStandard) { - storage.setBlocked(listOf(recipient), blocked) - } - } - - /** - * This will delete these messages from the db - * Not to be confused with 'marking messages as deleted' - */ - override fun deleteMessages(messages: Set) { - // split the messages into mms and sms - val (mms, sms) = messages.partition { it.isMms } - - if(mms.isNotEmpty()){ - messageDataProvider.deleteMessages(mms.map { it.id }, isSms = false) - } - - if(sms.isNotEmpty()){ - messageDataProvider.deleteMessages(sms.map { it.id }, isSms = true) - } - } - - /** - * This will mark the messages as deleted. - * They won't be removed from the db but instead will appear as a special type - * of message that says something like "This message was deleted" - */ - override fun markAsDeletedLocally(messages: Set, displayedMessage: String) { - // split the messages into mms and sms - val (mms, sms) = messages.partition { it.isMms } - - if(mms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted( - mms.map { MarkAsDeletedMessage( - messageId = it.messageId, - isOutgoing = it.isOutgoing - ) }, - displayedMessage = displayedMessage - ) - - // delete reactions - storage.deleteReactions(messageIds = mms.map { it.id }, mms = true) - } - - if(sms.isNotEmpty()){ - messageDataProvider.markMessagesAsDeleted( - sms.map { MarkAsDeletedMessage( - messageId = it.messageId, - isOutgoing = it.isOutgoing - ) }, - displayedMessage = displayedMessage - ) - - // delete reactions - storage.deleteReactions(messageIds = sms.map { it.id }, mms = false) - } - } - - override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { - val threadId = messageRecord.threadId - val senderId = messageRecord.recipient.address.address - val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) - for (message in messageRecordsToRemoveFromLocalStorage) { - messageDataProvider.deleteMessage(messageId = message.messageId) - } - } - - override suspend fun deleteCommunityMessagesRemotely( - community: Address.Community, - messages: Set - ) { - messages.forEach { message -> - lokiMessageDb.getServerID(message.messageId)?.let { messageServerID -> - OpenGroupApi.deleteMessage(messageServerID, community.room, community.serverUrl) - } - } - } - - override suspend fun delete1on1MessagesRemotely( - recipient: Address, - messages: Set - ) { - // delete the messages remotely - val userAuth = requireNotNull(storage.userAuth) { - "User auth is required to delete messages remotely" - } - val userAddress = userAuth.accountId.toAddress() - - messages.forEach { message -> - // delete from swarm - messageDataProvider.getServerHashForMessage(message.messageId) - ?.let { serverHash -> - SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) - } - - // send an UnsendRequest to user's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, userAddress) - } - - // send an UnsendRequest to recipient's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, recipient) - } - } - } - - override suspend fun deleteLegacyGroupMessagesRemotely( - recipient: Address, - messages: Set - ) { - if (recipient.isLegacyGroup) { - messages.forEach { message -> - // send an UnsendRequest to group's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, recipient) - } - } - } - } - - override suspend fun deleteGroupV2MessagesRemotely( - recipient: Address, - messages: Set - ) { - require(recipient.isGroupV2) { "Recipient is not a group v2 recipient" } - - val groupId = AccountId(recipient.address) - val hashes = messages.mapNotNullTo(mutableSetOf()) { msg -> - messageDataProvider.getServerHashForMessage(msg.messageId) - } - - groupManager.requestMessageDeletion(groupId, hashes) - } - - override suspend fun deleteNoteToSelfMessagesRemotely( - recipient: Address, - messages: Set - ) { - // delete the messages remotely - val userAuth = requireNotNull(storage.userAuth) { - "User auth is required to delete messages remotely" - } - val userAddress = userAuth.accountId.toAddress() - - messages.forEach { message -> - // delete from swarm - messageDataProvider.getServerHashForMessage(message.messageId) - ?.let { serverHash -> - SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) - } - - // send an UnsendRequest to user's swarm - buildUnsendRequest(message).let { unsendRequest -> - messageSender.send(unsendRequest, userAddress) - } - } - } - - private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { - return UnsendRequest( - author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.address } - ?: loginStateRepository.requireLocalNumber(), - timestamp = message.timestamp - ) - } - - override suspend fun banUser(community: Address.Community, userId: AccountId): Result = runCatching { - OpenGroupApi.ban( - publicKey = userId.hexString, - room = community.room, - server = community.serverUrl, - ) - } - - override suspend fun banAndDeleteAll(community: Address.Community, userId: AccountId) = runCatching { - // Note: This accountId could be the blinded Id - OpenGroupApi.banAndDeleteAll( - publicKey = userId.hexString, - room = community.room, - server = community.serverUrl - ) - } - - override suspend fun deleteMessageRequest(thread: ThreadRecord): Result { - val address = thread.recipient.address as? Address.Conversable ?: return Result.success(Unit) - - return declineMessageRequest( - address - ) - } - - override suspend fun clearAllMessageRequests() = runCatching { - - configFactory.withMutableUserConfigs { configs -> - // Go through all contacts - configs.contacts.all() - .asSequence() - .filter { !it.approved } - .forEach { - configs.contacts.erase(it.id) - } - - - // Go through all invited groups - configs.userGroups.allClosedGroupInfo() - .asSequence() - .filter { it.invited } - .forEach { g -> - configs.userGroups.eraseClosedGroup(g.groupAccountId) - } - } - } - - override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int { - return withContext(Dispatchers.Default) { - // delete data locally - val deletedHashes = storage.clearAllMessages(threadId) - Log.i("", "Cleared messages with hashes: $deletedHashes") - - // if required, also sync groupV2 data - if (groupId != null) { - groupManager.clearAllMessagesForEveryone(groupId, deletedHashes) - } - - deletedHashes.size - } - } - - override suspend fun acceptMessageRequest(recipient: Address.Conversable) = runCatching { - when (recipient) { - is Address.Standard -> { - configFactory.withMutableUserConfigs { configs -> - configs.contacts.upsertContact(recipient) { - approved = true - } - } - - withContext(Dispatchers.Default) { - messageSender.send(message = MessageRequestResponse(true), address = recipient) - - // add a control message for our user - storage.insertMessageRequestResponseFromYou(threadDb.getOrCreateThreadIdFor(recipient)) - } - } - - is Address.Group -> { - groupManager.respondToInvitation( - recipient.accountId, - approved = true - ) - } - - is Address.Community, - is Address.CommunityBlindedId, - is Address.LegacyGroup -> { - // These addresses are not supported for message requests - } - } - - Unit - } - - override suspend fun declineMessageRequest(recipient: Address.Conversable): Result = runCatching { - when (recipient) { - is Address.Standard -> { - configFactory.removeContactOrBlindedContact(recipient) - } - - is Address.Group -> { - groupManager.respondToInvitation( - recipient.accountId, - approved = false - ) - } - - is Address.Community, - is Address.CommunityBlindedId, - is Address.LegacyGroup -> { - // These addresses are not supported for message requests - } - } - } - - override fun hasReceived(threadId: Long): Boolean { - val cursor = mmsSmsDb.getConversation(threadId, true) - mmsSmsDb.readerFor(cursor).use { reader -> - while (reader.next != null) { - if (!reader.current.isOutgoing) { return true } - } - } - return false - } - - // Only call this with a closed group thread ID - override fun getInvitingAdmin(threadId: Long): Address? { - return lokiMessageDb.groupInviteReferrer(threadId)?.let(Address::fromSerialized) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt new file mode 100644 index 0000000000..601dcbcff1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt @@ -0,0 +1,566 @@ +package org.thoughtcrime.securesms.repository + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupInfo +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.messages.MarkAsDeletedMessage +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.OpenGroupInvitation +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.isGroupV2 +import org.session.libsession.utilities.isLegacyGroup +import org.session.libsession.utilities.isStandard +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsession.utilities.upsertContact +import org.session.libsession.utilities.userConfigsChanged +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.database.CommunityDatabase +import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.RecipientSettingsDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.castAwayType +import java.util.EnumSet +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DefaultConversationRepository @Inject constructor( + private val messageDataProvider: MessageDataProvider, + private val threadDb: ThreadDatabase, + private val communityDatabase: CommunityDatabase, + private val draftDb: DraftDatabase, + private val smsDb: SmsDatabase, + private val mmsSmsDb: MmsSmsDatabase, + private val storage: Storage, + private val lokiMessageDb: LokiMessageDatabase, + private val configFactory: ConfigFactory, + private val groupManager: GroupManagerV2, + private val clock: SnodeClock, + private val recipientDatabase: RecipientSettingsDatabase, + private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val loginStateRepository: LoginStateRepository, + private val proStatusManager: ProStatusManager, +) : ConversationRepository { + + override val conversationListAddressesFlow get() = loginStateRepository.flowWithLoggedInState { + configFactory + .userConfigsChanged( + EnumSet.of( + UserConfigType.CONTACTS, + UserConfigType.USER_PROFILE, + UserConfigType.USER_GROUPS + )) + .castAwayType() + .onStart { + emit(Unit) + } + .map { getConversationListAddresses() } + } + + private fun getConversationListAddresses() = buildSet { + val myAddress = loginStateRepository.getLocalNumber()?.toAddress() as? Address.Standard + ?: return@buildSet + + // Always have NTS - we should only "hide" them on home screen - the convo should never be deleted + add(myAddress) + + configFactory.withUserConfigs { configs -> + // Contacts + for (contact in configs.contacts.all()) { + if (contact.priority >= 0 && (!contact.blocked || contact.approved)) { + add(Address.Standard(AccountId(contact.id))) + } + } + + // Blinded Contacts + for (blindedContact in configs.contacts.allBlinded()) { + if (blindedContact.priority >= 0) { + add( + Address.CommunityBlindedId( + serverUrl = blindedContact.communityServer, + blindedId = Address.Blinded(AccountId(blindedContact.id)) + )) + } + } + + // Groups + for (group in configs.userGroups.all()) { + when (group) { + is GroupInfo.ClosedGroupInfo -> { + add(Address.Group(AccountId(group.groupAccountId))) + } + + is GroupInfo.LegacyGroupInfo -> { + add(Address.LegacyGroup(group.accountId)) + } + + is GroupInfo.CommunityGroupInfo -> { + add( + Address.Community( + serverUrl = group.community.baseUrl, + room = group.community.room + )) + } + } + } + } + } + + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + override fun observeConversationList(): Flow> { + return conversationListAddressesFlow + .flatMapLatest { allAddresses -> + merge( + configFactory.configUpdateNotifications, + recipientDatabase.changeNotification.filter { it in allAddresses }, + communityDatabase.changeNotification.filter { it in allAddresses }, + threadDb.updateNotifications, + // If pro status pref changes, the convo is likely needing changes too + TextSecurePreferences.Companion.events.filter { + it == TextSecurePreferences.Companion.SET_FORCE_OTHER_USERS_PRO || + it == TextSecurePreferences.Companion.SET_FORCE_CURRENT_USER_PRO + it == TextSecurePreferences.Companion.SET_FORCE_POST_PRO + } + ).debounce(500) + .onStart { emit(allAddresses) } + .map { allAddresses } + } + .map { addresses -> + withContext(Dispatchers.Default) { + threadDb.getThreads(addresses) + } + } + } + + override fun getConversationList(): List { + return threadDb.getThreads(getConversationListAddresses()) + } + + override fun saveDraft(threadId: Long, text: String) { + if (text.isEmpty()) return + val drafts = DraftDatabase.Drafts() + drafts.add(DraftDatabase.Draft(DraftDatabase.Draft.TEXT, text)) + draftDb.insertDrafts(threadId, drafts) + } + + override fun getDraft(threadId: Long): String? { + val drafts = draftDb.getDrafts(threadId) + return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value + } + + override fun clearDrafts(threadId: Long) { + draftDb.clearDrafts(threadId) + } + + override fun inviteContactsToCommunity( + communityRecipient: Recipient, + contacts: Collection
+ ) { + val community = communityRecipient.data as? RecipientData.Community + val info = community?.roomInfo ?: return + for (contact in contacts) { + val message = VisibleMessage() + message.sentTimestamp = clock.currentTimeMills() + val openGroupInvitation = OpenGroupInvitation().apply { + name = info.details.name + url = community.joinURL + } + message.openGroupInvitation = openGroupInvitation + proStatusManager.addProFeatures(message) + val contactThreadId = threadDb.getOrCreateThreadIdFor(contact) + val expirationConfig = recipientRepository.getRecipientSync(contact).expiryMode + val expireStartedAt = if (expirationConfig is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 + val outgoingTextMessage = OutgoingTextMessage.Companion.fromOpenGroupInvitation( + openGroupInvitation, + contact, + message.sentTimestamp!!, + expirationConfig.expiryMillis, + expireStartedAt, + proFeatures = message.proFeatures + )!! + + message.id = MessageId( + smsDb.insertMessageOutbox( + contactThreadId, + outgoingTextMessage, + false, + message.sentTimestamp!!, + true + ), + false + ) + + messageSender.send(message, contact) + } + } + + override fun isGroupReadOnly(recipient: Recipient): Boolean { + // We only care about group v2 recipient + if (!recipient.isGroupV2Recipient) { + return false + } + + val groupId = recipient.address.toString() + return configFactory.withUserConfigs { configs -> + configs.userGroups.getClosedGroup(groupId)?.let { it.kicked || it.destroyed } == true + } + } + + override fun getLastSentMessageID(threadId: Long): Flow { + return (threadDb.updateNotifications.filter { it == threadId } as Flow<*>) + .onStart { emit(Unit) } + .map { + withContext(Dispatchers.Default) { + mmsSmsDb.getLastSentMessageID(threadId) + } + } + } + + // This assumes that recipient.isContactRecipient is true + override fun setBlocked(recipient: Address, blocked: Boolean) { + if (recipient.isStandard) { + storage.setBlocked(listOf(recipient), blocked) + } + } + + /** + * This will delete these messages from the db + * Not to be confused with 'marking messages as deleted' + */ + override fun deleteMessages(messages: Set) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.deleteMessages(mms.map { it.id }, isSms = false) + } + + if(sms.isNotEmpty()){ + messageDataProvider.deleteMessages(sms.map { it.id }, isSms = true) + } + } + + /** + * This will mark the messages as deleted. + * They won't be removed from the db but instead will appear as a special type + * of message that says something like "This message was deleted" + */ + override fun markAsDeletedLocally(messages: Set, displayedMessage: String) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted( + mms.map { + MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) + }, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = mms.map { it.id }, mms = true) + } + + if(sms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted( + sms.map { + MarkAsDeletedMessage( + messageId = it.messageId, + isOutgoing = it.isOutgoing + ) + }, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = sms.map { it.id }, mms = false) + } + } + + override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { + val threadId = messageRecord.threadId + val senderId = messageRecord.recipient.address.address + val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) + for (message in messageRecordsToRemoveFromLocalStorage) { + messageDataProvider.deleteMessage(messageId = message.messageId) + } + } + + override suspend fun deleteCommunityMessagesRemotely( + community: Address.Community, + messages: Set + ) { + messages.forEach { message -> + lokiMessageDb.getServerID(message.messageId)?.let { messageServerID -> + OpenGroupApi.deleteMessage(messageServerID, community.room, community.serverUrl) + } + } + } + + override suspend fun delete1on1MessagesRemotely( + recipient: Address, + messages: Set + ) { + // delete the messages remotely + val userAuth = requireNotNull(storage.userAuth) { + "User auth is required to delete messages remotely" + } + val userAddress = userAuth.accountId.toAddress() + + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.messageId) + ?.let { serverHash -> + SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, userAddress) + } + + // send an UnsendRequest to recipient's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, recipient) + } + } + } + + override suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Address, + messages: Set + ) { + if (recipient.isLegacyGroup) { + messages.forEach { message -> + // send an UnsendRequest to group's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, recipient) + } + } + } + } + + override suspend fun deleteGroupV2MessagesRemotely( + recipient: Address, + messages: Set + ) { + require(recipient.isGroupV2) { "Recipient is not a group v2 recipient" } + + val groupId = AccountId(recipient.address) + val hashes = messages.mapNotNullTo(mutableSetOf()) { msg -> + messageDataProvider.getServerHashForMessage(msg.messageId) + } + + groupManager.requestMessageDeletion(groupId, hashes) + } + + override suspend fun deleteNoteToSelfMessagesRemotely( + recipient: Address, + messages: Set + ) { + // delete the messages remotely + val userAuth = requireNotNull(storage.userAuth) { + "User auth is required to delete messages remotely" + } + val userAddress = userAuth.accountId.toAddress() + + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.messageId) + ?.let { serverHash -> + SnodeAPI.deleteMessage(recipient.address, userAuth, listOf(serverHash)) + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(message).let { unsendRequest -> + messageSender.send(unsendRequest, userAddress) + } + } + } + + private fun buildUnsendRequest(message: MessageRecord): UnsendRequest { + return UnsendRequest( + author = message.takeUnless { it.isOutgoing } + ?.run { individualRecipient.address.address } + ?: loginStateRepository.requireLocalNumber(), + timestamp = message.timestamp + ) + } + + override suspend fun banUser(community: Address.Community, userId: AccountId): Result = runCatching { + OpenGroupApi.ban( + publicKey = userId.hexString, + room = community.room, + server = community.serverUrl, + ) + } + + override suspend fun banAndDeleteAll(community: Address.Community, userId: AccountId) = runCatching { + // Note: This accountId could be the blinded Id + OpenGroupApi.banAndDeleteAll( + publicKey = userId.hexString, + room = community.room, + server = community.serverUrl + ) + } + + override suspend fun deleteMessageRequest(thread: ThreadRecord): Result { + val address = thread.recipient.address as? Address.Conversable ?: return Result.success(Unit) + + return declineMessageRequest( + address + ) + } + + override suspend fun clearAllMessageRequests() = runCatching { + + configFactory.withMutableUserConfigs { configs -> + // Go through all contacts + configs.contacts.all() + .asSequence() + .filter { !it.approved } + .forEach { + configs.contacts.erase(it.id) + } + + + // Go through all invited groups + configs.userGroups.allClosedGroupInfo() + .asSequence() + .filter { it.invited } + .forEach { g -> + configs.userGroups.eraseClosedGroup(g.groupAccountId) + } + } + } + + override suspend fun clearAllMessages(threadId: Long, groupId: AccountId?): Int { + return withContext(Dispatchers.Default) { + // delete data locally + val deletedHashes = storage.clearAllMessages(threadId) + Log.i("", "Cleared messages with hashes: $deletedHashes") + + // if required, also sync groupV2 data + if (groupId != null) { + groupManager.clearAllMessagesForEveryone(groupId, deletedHashes) + } + + deletedHashes.size + } + } + + override suspend fun acceptMessageRequest(recipient: Address.Conversable) = runCatching { + when (recipient) { + is Address.Standard -> { + configFactory.withMutableUserConfigs { configs -> + configs.contacts.upsertContact(recipient) { + approved = true + } + } + + withContext(Dispatchers.Default) { + messageSender.send(message = MessageRequestResponse(true), address = recipient) + + // add a control message for our user + storage.insertMessageRequestResponseFromYou( + threadDb.getOrCreateThreadIdFor( + recipient + ) + ) + } + } + + is Address.Group -> { + groupManager.respondToInvitation( + recipient.accountId, + approved = true + ) + } + + is Address.Community, + is Address.CommunityBlindedId, + is Address.LegacyGroup -> { + // These addresses are not supported for message requests + } + } + + Unit + } + + override suspend fun declineMessageRequest(recipient: Address.Conversable): Result = runCatching { + when (recipient) { + is Address.Standard -> { + configFactory.removeContactOrBlindedContact(recipient) + } + + is Address.Group -> { + groupManager.respondToInvitation( + recipient.accountId, + approved = false + ) + } + + is Address.Community, + is Address.CommunityBlindedId, + is Address.LegacyGroup -> { + // These addresses are not supported for message requests + } + } + } + + override fun hasReceived(threadId: Long): Boolean { + val cursor = mmsSmsDb.getConversation(threadId, true) + mmsSmsDb.readerFor(cursor).use { reader -> + while (reader.next != null) { + if (!reader.current.isOutgoing) { return true } + } + } + return false + } + + // Only call this with a closed group thread ID + override fun getInvitingAdmin(threadId: Long): Address? { + return lokiMessageDb.groupInviteReferrer(threadId)?.let(Address.Companion::fromSerialized) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt index 16bde5de03..f210f20927 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt @@ -1,14 +1,19 @@ package org.thoughtcrime.securesms.search -import android.content.Context import android.database.Cursor -import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import network.loki.messenger.libsession_util.util.GroupInfo import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsession.utilities.concurrent.SignalExecutors import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.recipients.displayName @@ -17,6 +22,7 @@ import org.thoughtcrime.securesms.database.CursorList import org.thoughtcrime.securesms.database.MmsSmsColumns import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.SearchResult @@ -27,42 +33,38 @@ import javax.inject.Singleton // Class to manage data retrieval for search @Singleton class SearchRepository @Inject constructor( - @param:ApplicationContext private val context: Context, private val searchDatabase: SearchDatabase, private val recipientRepository: RecipientRepository, - private val conversationRepository: ConversationRepository, + private val conversationRepository: Lazy, private val configFactory: ConfigFactoryProtocol, + @param:ManagerScope private val scope: CoroutineScope, ) { - private val executor = SignalExecutors.SERIAL + private val searchSemaphore = Semaphore(1) - fun query(query: String, callback: (SearchResult) -> Unit) { - // If the sanitized search is empty then abort without search - val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } + suspend fun query(query: String): SearchResult = withContext(Dispatchers.Default) { + searchSemaphore.withPermit { + // If the sanitized search is empty then abort without search + val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } - executor.execute { - val timer = - Stopwatch("FtsQuery") + val timer = Stopwatch("FtsQuery") timer.split("clean") - val contacts = - queryContacts(cleanQuery) + val contacts = queryContacts(cleanQuery) timer.split("Contacts") - val conversations = - queryConversations(cleanQuery) + val conversations = queryConversations(cleanQuery) timer.split("Conversations") val messages = queryMessages(cleanQuery) timer.split("Messages") timer.stop(TAG) - callback( - SearchResult( - cleanQuery, - contacts, - conversations, - messages - ) + + SearchResult( + cleanQuery, + contacts, + conversations, + messages ) } } @@ -75,9 +77,10 @@ class SearchRepository @Inject constructor( return } - executor.execute { - val messages = queryMessages(cleanQuery, threadId) - callback(messages) + scope.launch { + searchSemaphore.withPermit { + callback(queryMessages(cleanQuery, threadId)) + } } } @@ -157,8 +160,8 @@ class SearchRepository @Inject constructor( .toList() } - private fun queryMessages(query: String): CursorList { - val allConvo = conversationRepository.conversationListAddressesFlow.value + private suspend fun queryMessages(query: String): CursorList { + val allConvo = conversationRepository.get().conversationListAddressesFlow.first() val messages = searchDatabase.queryMessages(query, allConvo) return if (messages != null) CursorList(messages, MessageModelBuilder()) @@ -213,10 +216,6 @@ class SearchRepository @Inject constructor( } } - interface Callback { - fun onResult(result: E) - } - companion object { private val TAG: String = SearchRepository::class.java.simpleName From d5ed05c86fcb579d89072bb1645c9e4b31c2f996 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:07:53 +1100 Subject: [PATCH 2/2] Tidy up --- .../securesms/search/SearchRepository.kt | 11 ---- .../securesms/util/Stopwatch.java | 56 ------------------- 2 files changed, 67 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt index f210f20927..3f2ec98b33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.kt @@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.SearchResult -import org.thoughtcrime.securesms.util.Stopwatch import javax.inject.Inject import javax.inject.Singleton @@ -46,19 +45,9 @@ class SearchRepository @Inject constructor( // If the sanitized search is empty then abort without search val cleanQuery = sanitizeQuery(query).trim { it <= ' ' } - val timer = Stopwatch("FtsQuery") - timer.split("clean") - val contacts = queryContacts(cleanQuery) - timer.split("Contacts") - val conversations = queryConversations(cleanQuery) - timer.split("Conversations") - val messages = queryMessages(cleanQuery) - timer.split("Messages") - - timer.stop(TAG) SearchResult( cleanQuery, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java b/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java deleted file mode 100644 index d92fc7546d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import androidx.annotation.NonNull; - -import org.session.libsignal.utilities.Log; - -import java.util.LinkedList; -import java.util.List; - -public class Stopwatch { - - private final long startTime; - private final String title; - private final List splits; - - public Stopwatch(@NonNull String title) { - this.startTime = System.currentTimeMillis(); - this.title = title; - this.splits = new LinkedList<>(); - } - - public void split(@NonNull String label) { - splits.add(new Split(System.currentTimeMillis(), label)); - } - - public void stop(@NonNull String tag) { - StringBuilder out = new StringBuilder(); - out.append("[").append(title).append("] "); - - if (splits.size() > 0) { - out.append(splits.get(0).label).append(": "); - out.append(splits.get(0).time - startTime); - out.append(" "); - } - - if (splits.size() > 1) { - for (int i = 1; i < splits.size(); i++) { - out.append(splits.get(i).label).append(": "); - out.append(splits.get(i).time - splits.get(i - 1).time); - out.append("ms "); - } - out.append("total: ").append(splits.get(splits.size() - 1).time - startTime).append("ms."); - } - Log.d(tag, out.toString()); - } - - private static class Split { - final long time; - final String label; - - Split(long time, String label) { - this.time = time; - this.label = label; - } - } -}