diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index f5f08e696d..97df177ca1 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -141,14 +141,12 @@ interface StorageProtocol { fun getOrCreateThreadIdFor(address: Address): Long fun getThreadId(address: Address): Long? fun getThreadIdForMms(mmsId: Long): Long - fun getLastUpdated(threadID: Long): Long fun trimThreadBefore(threadID: Long, timestamp: Long) fun getMessageCount(threadID: Long): Long fun getTotalPinned(): Int suspend fun getTotalSentProBadges(): Int suspend fun getTotalSentLongMessages(): Int fun setPinned(address: Address, isPinned: Boolean) - fun isRead(threadId: Long) : Boolean fun setThreadCreationDate(threadId: Long, newDate: Long) fun getLastLegacyRecipient(threadRecipient: String): String? fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) @@ -175,7 +173,9 @@ interface StorageProtocol { attachments: List, runThreadUpdate: Boolean ): MessageId? - fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false, updateNotification: Boolean = true) + + fun updateConversationLastSeenIfNeeded(threadAddress: Address.Conversable, lastSeenTime: Long) + fun updateConversationLastSeenIfNeeded(threadId: Long, lastSeenTime: Long) /** * Marks the conversation as read up to and including the message with [messageId]. It will @@ -185,13 +185,11 @@ interface StorageProtocol { */ fun markConversationAsReadUpToMessage(messageId: MessageId) fun markConversationAsUnread(threadId: Long) - fun getLastSeen(threadId: Long): Long + fun getLastSeen(threadAddress: Address.Conversable): Long? fun ensureMessageHashesAreSender(hashes: Set, sender: String, closedGroupId: String): Boolean - fun updateThread(threadId: Long, unarchive: Boolean) fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) fun insertMessageRequestResponseFromYou(threadId: Long) fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long, expiryMode: ExpiryMode) - fun conversationHasOutgoing(userPublicKey: String): Boolean fun deleteMessagesByHash(threadId: Long, hashes: List) fun deleteMessagesByUser(threadId: Long, userSessionId: String) fun clearAllMessages(threadId: Long): List diff --git a/app/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt index 8e3ab18b22..a484a8e9d8 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt @@ -7,11 +7,7 @@ data class ExpirationConfiguration( val expiryMode: ExpiryMode = ExpiryMode.NONE, val updatedTimestampMs: Long = 0 ) { - val isEnabled = expiryMode.expirySeconds > 0 - - companion object { - val isNewConfigEnabled = true - } + val isEnabled get() = expiryMode.expirySeconds > 0 } data class ExpirationDatabaseMetadata( diff --git a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt index 4201b9bc6c..d7561c8a36 100644 --- a/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt +++ b/app/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt @@ -3,11 +3,7 @@ package org.session.libsession.messaging.open_groups import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import network.loki.messenger.libsession_util.util.BaseCommunityInfo -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.session.libsession.messaging.open_groups.OpenGroup.Companion.toAddress import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil @Deprecated("This class is no longer used except in migration. Use RoomInfo instead") @Serializable @@ -27,20 +23,15 @@ data class OpenGroup( val id: String get() = groupId companion object { - /** * Returns the group ID for this community info. The group ID is the session android unique * way of identifying a community. It itself isn't super useful but it's used to construct * the [Address] for communities. * - * See [toAddress] */ val BaseCommunityInfo.groupId: String get() = "${baseUrl}.${room}" - fun BaseCommunityInfo.toAddress(): Address { - return Address.fromSerialized(GroupUtil.getEncodedOpenGroupID(groupId.toByteArray())) - } } val groupId: String get() = "$server.$room" diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index 99e517673e..6d10bb57a8 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -173,7 +173,6 @@ class MessageRequestResponseHandler @Inject constructor( dataExtractionNotification = null ), threadId, - runThreadUpdate = true, ) } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt index 069a2ac2e0..8656b02736 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -107,18 +107,12 @@ class ReceivedMessageProcessor @Inject constructor( try { return block(context) } finally { - for (threadId in context.threadIDs.values) { - if (context.maxOutgoingMessageTimestamp > 0L && - context.maxOutgoingMessageTimestamp > storage.getLastSeen(threadId) - ) { - storage.markConversationAsRead( - threadId, - context.maxOutgoingMessageTimestamp, - force = true - ) - } + for ((threadAddress, threadId) in context.threadIDs) { + storage.updateConversationLastSeenIfNeeded( + threadAddress = threadAddress, + context.maxOutgoingMessageTimestamp, + ) - storage.updateThread(threadId, true) notificationManager.updateNotification(this.context, threadId) } diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 81a63f787d..d79fe349e9 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -27,7 +27,6 @@ import network.loki.messenger.libsession_util.protocol.ProFeature import network.loki.messenger.libsession_util.protocol.ProMessageFeature import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.toBitSet -import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.file_server.FileServer import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED @@ -102,8 +101,6 @@ interface TextSecurePreferences { fun getNeedsSqlCipherMigration(): Boolean fun isIncognitoKeyboardEnabled(): Boolean fun setIncognitoKeyboardEnabled(enabled : Boolean) - fun isReadReceiptsEnabled(): Boolean - fun setReadReceiptsEnabled(enabled: Boolean) fun isTypingIndicatorsEnabled(): Boolean fun setTypingIndicatorsEnabled(enabled: Boolean) fun isLinkPreviewsEnabled(): Boolean @@ -265,8 +262,6 @@ interface TextSecurePreferences { var migratedToGroupV2Config: Boolean var migratedToDisablingKDF: Boolean - var migratedDisappearingMessagesToMessageContent: Boolean - var selectedActivityAliasName: String? var inAppReviewState: String? @@ -305,7 +300,6 @@ interface TextSecurePreferences { const val REPEAT_ALERTS_PREF = "pref_repeat_alerts" const val NOTIFICATION_PRIVACY_PREF = "pref_notification_privacy" const val DIRECT_CAPTURE_CAMERA_ID = "pref_direct_capture_camera_id" - const val READ_RECEIPTS_PREF = "pref_read_receipts" const val INCOGNITO_KEYBOARD_PREF = "pref_incognito_keyboard" const val NEEDS_SQLCIPHER_MIGRATION = "pref_needs_sql_cipher_migration" const val BACKUP_ENABLED = "pref_backup_enabled_v3" @@ -451,11 +445,6 @@ interface TextSecurePreferences { return getBooleanPreference(context, INCOGNITO_KEYBOARD_PREF, true) } - @JvmStatic - fun isReadReceiptsEnabled(context: Context): Boolean { - return getBooleanPreference(context, READ_RECEIPTS_PREF, false) - } - @JvmStatic fun isGifSearchInGridLayout(context: Context): Boolean { return getBooleanPreference(context, GIF_GRID_LAYOUT, false) @@ -673,10 +662,6 @@ class AppTextSecurePreferences @Inject constructor( putBoolean(TextSecurePreferences.MIGRATED_TO_DISABLING_KDF, value) } - override var migratedDisappearingMessagesToMessageContent: Boolean - get() = getBooleanPreference("migrated_disappearing_messages_to_message_content", false) - set(value) = setBooleanPreference("migrated_disappearing_messages_to_message_content", value) - override fun getConfigurationMessageSynced(): Boolean { return getBooleanPreference(TextSecurePreferences.CONFIGURATION_SYNCED, false) } @@ -771,15 +756,6 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.INCOGNITO_KEYBOARD_PREF) } - override fun isReadReceiptsEnabled(): Boolean { - return getBooleanPreference(TextSecurePreferences.READ_RECEIPTS_PREF, false) - } - - override fun setReadReceiptsEnabled(enabled: Boolean) { - setBooleanPreference(TextSecurePreferences.READ_RECEIPTS_PREF, enabled) - _events.tryEmit(TextSecurePreferences.READ_RECEIPTS_PREF) - } - override fun isTypingIndicatorsEnabled(): Boolean { return getBooleanPreference(TextSecurePreferences.TYPING_INDICATORS, false) } diff --git a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt index 1691a7342f..41bceea0ec 100644 --- a/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt +++ b/app/src/main/java/org/session/libsession/utilities/recipients/RecipientData.kt @@ -9,7 +9,9 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.utilities.Address +import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import java.time.Instant /** @@ -134,17 +136,24 @@ sealed interface RecipientData { * A recipient that was saved in your contact config. */ data class Contact( - val name: String, - val nickname: String?, - override val avatar: RemoteFile.Encrypted?, - val approved: Boolean, - val approvedMe: Boolean, - val blocked: Boolean, - val expiryMode: ExpiryMode, - override val priority: Long, + private val configData: network.loki.messenger.libsession_util.util.Contact, override val proData: ProData?, - override val profileUpdatedAt: Instant?, ) : RecipientData { + val name: String get() = configData.name + val nickname: String? get() = configData.nickname.takeIf { it.isNotBlank() } + val approved: Boolean get() = configData.approved + val approvedMe: Boolean get() = configData.approvedMe + val blocked: Boolean get() = configData.blocked + val createdAt: Instant get() = Instant.ofEpochSecond(configData.createdEpochSeconds) + override val priority: Long get() = configData.priority + override val profileUpdatedAt: Instant? get() = configData.profileUpdatedEpochSeconds + .secondsToInstant() + + val expiryMode: ExpiryMode get() = configData.expiryMode + + override val avatar: RemoteFile? + get() = configData.profilePicture.toRemoteFile() + val displayName: String get() = nickname?.takeIf { it.isNotBlank() } ?: name @@ -186,6 +195,7 @@ sealed interface RecipientData { val destroyed: Boolean get() = groupInfo.destroyed val shouldPoll: Boolean get() = groupInfo.shouldPoll override val proData: ProData? get() = null //todo LARGE GROUP hiding group pro status until we enable large groups + val joinedAt: Instant get() = Instant.ofEpochSecond(groupInfo.joinedAtSecs) override val profileUpdatedAt: Instant? get() = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponents.kt index b7734f9f37..e4389b9ee9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/auth/AuthAwareComponents.kt @@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.groups.handler.CleanupInvitationHandler import org.thoughtcrime.securesms.groups.handler.DestroyedGroupSync import org.thoughtcrime.securesms.groups.handler.RemoveGroupMemberHandler import org.thoughtcrime.securesms.notifications.BackgroundPollManager +import org.thoughtcrime.securesms.notifications.MarkReadProcessor import org.thoughtcrime.securesms.notifications.PushRegistrationHandler import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.service.ExpiringMessageManager @@ -42,6 +43,7 @@ class AuthAwareComponents( proStatusManager: Lazy, pollerManager: Lazy, backgroundPollManager: Lazy, + markReadProcessor: Lazy, versionDataFetcher: Lazy, ): this( components = listOf>( @@ -59,6 +61,7 @@ class AuthAwareComponents( pollerManager, backgroundPollManager, versionDataFetcher, + markReadProcessor, ) ) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 0b151df9f4..f87d929a20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -7,11 +7,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import network.loki.messenger.R import network.loki.messenger.libsession_util.ReadableGroupInfoConfig import network.loki.messenger.libsession_util.util.Conversation @@ -56,13 +54,15 @@ import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.upsertThreadLastSeen import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.util.SessionMetaProtocol import org.thoughtcrime.securesms.util.castAwayType -import java.util.EnumSet +import org.thoughtcrime.securesms.util.erase import java.util.concurrent.TimeUnit import javax.inject.Inject +import kotlin.time.Instant private const val TAG = "ConfigToDatabaseSync" @@ -96,23 +96,55 @@ class ConfigToDatabaseSync @Inject constructor( private val deleteMessageApiFactory: DeleteMessageApi.Factory, @param:ManagerScope private val scope: CoroutineScope, ) : AuthAwareComponent { - override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { - combine( - conversationRepository.conversationListAddressesFlow, - configFactory.userConfigsChanged(EnumSet.of(UserConfigType.CONVO_INFO_VOLATILE)) + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = supervisorScope { + launch { + conversationRepository.conversationListAddressesFlow + .collectLatest { conversations -> + try { + ensureConversations(conversations, loggedInState.accountId) + } catch (e: Exception) { + Log.e(TAG, "Error updating conversations from config", e) + } + } + } + + launch { + configFactory.userConfigsChanged(onlyConfigTypes = setOf(UserConfigType.CONVO_INFO_VOLATILE)) .castAwayType() .onStart { emit(Unit) } - .map { _ -> configFactory.withUserConfigs { it.convoInfoVolatile.all() } }, - ::Pair - ).distinctUntilChanged() - .collectLatest { (conversations, convoInfo) -> - try { - ensureConversations(conversations, loggedInState.accountId) - updateConvoVolatile(convoInfo) - } catch (e: Exception) { - Log.e(TAG, "Error updating conversations from config", e) + .collectLatest { + try { + ensureThreadLastReads() + } catch (e: Exception) { + Log.e(TAG, "Error updating thread last reads from config", e) + } + } + } + } + + // Read conversation last reads from config system then sync to local db. + private fun ensureThreadLastReads() { + val lastReads = configFactory.withUserConfigs { it.convoInfoVolatile.all() } + .asSequence() + .mapNotNull { convo -> + val address = when (convo) { + is Conversation.ClosedGroup -> convo.accountId.toAddress() as Address.Group + is Conversation.Community -> Address.Community( + convo.baseCommunityInfo.baseUrl, + convo.baseCommunityInfo.room, + ) + is Conversation.LegacyGroup -> Address.LegacyGroup(convo.groupId) + is Conversation.OneToOne -> convo.accountId.toAddress() as Address.Standard + is Conversation.BlindedOneToOne, null -> return@mapNotNull null } + + address to Instant.fromEpochMilliseconds(convo.lastRead) } + .toList() + + if (lastReads.isNotEmpty()) { + threadDatabase.upsertThreadLastSeen(lastReads) + } } private fun ensureConversations(addresses: Set, myAccountId: AccountId) { @@ -120,7 +152,7 @@ class ConfigToDatabaseSync @Inject constructor( if (result.deletedThreads.isNotEmpty()) { val deletedThreadIDs = result.deletedThreads.values - smsDatabase.deleteThreads(deletedThreadIDs, false) + smsDatabase.deleteThreads(deletedThreadIDs) mmsDatabase.deleteThreads(deletedThreadIDs, updateThread = false) draftDatabase.clearDrafts(deletedThreadIDs) @@ -134,6 +166,15 @@ class ConfigToDatabaseSync @Inject constructor( // If you can find out what it does, please remove it. SessionMetaProtocol.clearReceivedMessages() + // Remove all convo info + configFactory.withMutableUserConfigs { configs -> + result.deletedThreads.keys.forEach { address -> + if (address is Address.Conversable) { + configs.convoInfoVolatile.erase(address) + } + } + } + // Some type of convo require additional cleanup, we'll go through them here for ((address, threadId) in result.deletedThreads) { storage.cancelPendingMessageSendJobs(threadId) @@ -352,32 +393,4 @@ class ConfigToDatabaseSync @Inject constructor( private val MmsMessageRecord.containsAttachment: Boolean get() = this.slideDeck.slides.isNotEmpty() && !this.slideDeck.isVoiceNote - - private fun updateConvoVolatile(convos: List) { - for (conversation in convos.asSequence().filterNotNull()) { - val address: Address.Conversable = when (conversation) { - is Conversation.OneToOne -> Address.Standard(AccountId(conversation.accountId)) - is Conversation.LegacyGroup -> Address.LegacyGroup(conversation.groupId) - is Conversation.Community -> Address.Community(serverUrl = conversation.baseCommunityInfo.baseUrl, room = conversation.baseCommunityInfo.room) - is Conversation.ClosedGroup -> Address.Group(AccountId(conversation.accountId)) // New groups will be managed bia libsession - is Conversation.BlindedOneToOne -> { - // Not supported yet - continue - } - } - - val threadId = storage.getThreadId(address) - - if (threadId != null) { - if (conversation.lastRead > storage.getLastSeen(threadId)) { - storage.markConversationAsRead( - threadId, - conversation.lastRead, - force = true - ) - storage.updateThread(threadId, false) - } - } - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt index 7423b8ecdb..6f34beb7ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -29,7 +29,6 @@ import org.thoughtcrime.securesms.ui.UINavigator @HiltViewModel(assistedFactory = DisappearingMessagesViewModel.Factory::class) class DisappearingMessagesViewModel @AssistedInject constructor( @Assisted private val address: Address, - @Assisted("isNewConfigEnabled") private val isNewConfigEnabled: Boolean, @Assisted("showDebugOptions") private val showDebugOptions: Boolean, @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, @@ -39,7 +38,7 @@ class DisappearingMessagesViewModel @AssistedInject constructor( private val _state = MutableStateFlow( State( - isNewConfigEnabled = isNewConfigEnabled, + isNewConfigEnabled = true, showDebugOptions = showDebugOptions ) ) @@ -95,7 +94,6 @@ class DisappearingMessagesViewModel @AssistedInject constructor( interface Factory { fun create( address: Address, - @Assisted("isNewConfigEnabled") isNewConfigEnabled: Boolean, @Assisted("showDebugOptions") showDebugOptions: Boolean, navigator: UINavigator ): DisappearingMessagesViewModel diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 8fd65ef538..59fd8c9cbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -95,7 +95,6 @@ import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 -import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage @@ -128,7 +127,6 @@ import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.isBlinded import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.displayName -import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.ListenableFuture import org.session.libsignal.utilities.Log @@ -224,7 +222,6 @@ import org.thoughtcrime.securesms.util.adapter.runWhenLaidOut import org.thoughtcrime.securesms.util.drawToBitmap import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut -import org.thoughtcrime.securesms.util.getConversationUnread import org.thoughtcrime.securesms.util.isFullyScrolled import org.thoughtcrime.securesms.util.isNearBottom import org.thoughtcrime.securesms.util.push @@ -402,8 +399,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private val adapter by lazy { val adapter = ConversationAdapter( this, - originalLastSeen = viewModel.threadId - ?.let { storage.getLastSeen(it) }, + storage.getLastSeen(viewModel.address), false, onItemPress = { message, position, view, event -> handlePress(message, position, view, event) @@ -722,11 +718,10 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, try { when (it) { is Long -> { - viewModel.threadId?.let { threadId -> - if (storage.getLastSeen(threadId) < it) { - storage.markConversationAsRead(threadId, it) - } - } + storage.updateConversationLastSeenIfNeeded( + viewModel.address, + it + ) } is MessageId -> { @@ -915,6 +910,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { return conversationLoaderFactory.create( threadID = viewModel.threadId, + threadAddress = viewModel.address, reverse = false, ) } @@ -959,26 +955,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } - // We should do this check regardless of whether we're restoring a saved scroll position. - if (firstLoad && unreadCount == 0 && adapter.itemCount > 0) { - lifecycleScope.launch(Dispatchers.Default) { - val isUnread = configFactory.withUserConfigs { - it.convoInfoVolatile.getConversationUnread( - viewModel.address, - ) - } - - viewModel.threadId?.let { threadId -> - if (isUnread) { - storage.markConversationAsRead( - threadId, - clock.currentTimeMillis() - ) - } - } - } - } - handleRecyclerViewScrolled() } } @@ -1212,8 +1188,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, private fun setUpOutdatedClientBanner() { val legacyRecipient = viewModel.legacyBannerRecipient(this) - val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && - legacyRecipient != null + val shouldShowLegacy = legacyRecipient != null binding.conversationHeader.outdatedDisappearingBanner.isVisible = shouldShowLegacy if (shouldShowLegacy) { @@ -1411,11 +1386,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, return } - val threadId = viewModel.threadId - if (threadId == null) return // Maybe don't scroll - - val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(threadId).first() - val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return + val lastSeenTimestamp = storage.getLastSeen(viewModel.address) + val lastSeenItemPosition = lastSeenTimestamp?.let(adapter::findLastSeenItemPosition) ?: return binding.conversationRecyclerView.runWhenLaidOut { layoutManager?.scrollToPositionWithOffset( @@ -1642,8 +1614,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, .put(GROUP_NAME_KEY, recipient.displayName()) .format() .toString() - - else -> "" } return } @@ -2446,8 +2416,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, viewModel.threadIdFlow.filterNotNull().first(), outgoingTextMessage, false, - message.sentTimestamp!!, - true + message.sentTimestamp!! ), false) message.id?.let{ @@ -2533,8 +2502,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, mmsDb.insertMessageOutbox( outgoingTextMessage, viewModel.threadIdFlow.filterNotNull().first(), - false, - runThreadUpdate = true + false ), mms = true ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt index 06d5b82a68..3b03c2d781 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt @@ -7,12 +7,14 @@ import android.database.MatrixCursor import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.database.MmsSmsDatabase -import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.MmsSmsDatabaseExt.getUnreadCount import org.thoughtcrime.securesms.util.AbstractCursorLoader class ConversationLoader @AssistedInject constructor( @Assisted private val threadID: Long?, + @Assisted private val threadAddress: Address.Conversable, @Assisted private val reverse: Boolean, application: Application, private val mmsSmsDatabase: MmsSmsDatabase, @@ -27,7 +29,7 @@ class ConversationLoader @AssistedInject constructor( return Data( messageCursor = mmsSmsDatabase.getConversation(id, reverse), - threadUnreadCount = mmsSmsDatabase.getUnreadCount(id), + threadUnreadCount = mmsSmsDatabase.getUnreadCount(threadAddress), ) } @@ -44,6 +46,6 @@ class ConversationLoader @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(threadID: Long?, reverse: Boolean): ConversationLoader + fun create(threadID: Long?, threadAddress: Address.Conversable, reverse: Boolean): ConversationLoader } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 4d94bb1dd8..fd94f59f80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -99,9 +99,11 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.ReactionDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.RecipientSettingsDatabase +import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.GroupThreadStatus import org.thoughtcrime.securesms.database.model.MessageId @@ -150,6 +152,8 @@ class ConversationViewModel @AssistedInject constructor( private val configFactory: ConfigFactory, private val groupManagerV2: GroupManagerV2, private val callManager: CallManager, + private val mmsDatabase: MmsDatabase, + private val smsDatabase: SmsDatabase, val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, val dateUtils: DateUtils, expiredGroupManager: ExpiredGroupManager, @@ -211,10 +215,20 @@ class ConversationViewModel @AssistedInject constructor( val conversationReloadNotification: SharedFlow<*> = merge( threadIdFlow .filterNotNull() - .flatMapLatest { id -> threadDb.updateNotifications.filter { it == id } }, + .flatMapLatest { threadId -> + merge( + merge( + mmsDatabase.changeNotification, + smsDatabase.changeNotification + ).filter { it.threadId == threadId }, + + threadDb.updateNotifications.filter { it == threadId } + ) + }, recipientSettingsDatabase.changeNotification.filter { it == address }, attachmentDatabase.changesNotification, reactionDb.changeNotification, + ).debounce(200L) // debounce to avoid too many reloads .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 4cc0276c5e..072b7c8577 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -114,8 +114,7 @@ class ControlMessageView : LinearLayout { expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) } - followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled - && !message.isOutgoing + followSetting.isVisible = !message.isOutgoing && messageContent.expiryMode != (message.individualRecipient?.expiryMode ?: ExpiryMode.NONE) && !threadRecipient.isGroupOrCommunityRecipient diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt index cea24dbcdf..505bebda7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/ConversationV3NavHost.kt @@ -21,15 +21,10 @@ import androidx.navigation.toRoute import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import network.loki.messenger.BuildConfig -import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen -import org.thoughtcrime.securesms.conversation.v3.settings.ConversationSettingsScreen -import org.thoughtcrime.securesms.conversation.v3.settings.ConversationSettingsViewModel -import org.thoughtcrime.securesms.conversation.v3.settings.notification.NotificationSettingsScreen -import org.thoughtcrime.securesms.conversation.v3.settings.notification.NotificationSettingsViewModel import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RouteAllMedia import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RouteConversation import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RouteConversationSettings @@ -43,6 +38,10 @@ import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.Rout import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RouteNotifications import org.thoughtcrime.securesms.conversation.v3.ConversationV3Destination.RoutePromoteMembers import org.thoughtcrime.securesms.conversation.v3.compose.ConversationScreen +import org.thoughtcrime.securesms.conversation.v3.settings.ConversationSettingsScreen +import org.thoughtcrime.securesms.conversation.v3.settings.ConversationSettingsViewModel +import org.thoughtcrime.securesms.conversation.v3.settings.notification.NotificationSettingsScreen +import org.thoughtcrime.securesms.conversation.v3.settings.notification.NotificationSettingsViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel @@ -442,7 +441,6 @@ fun ConversationV3NavHost( hiltViewModel { factory -> factory.create( address = address, - isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, showDebugOptions = BuildConfig.BUILD_TYPE != "release", navigator = navigator ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/settings/ConversationSettingsNavHost.kt index 45ec0fbf9a..f0d34de217 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v3/settings/ConversationSettingsNavHost.kt @@ -18,7 +18,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import network.loki.messenger.BuildConfig -import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesViewModel import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen @@ -319,7 +318,6 @@ fun ConversationSettingsNavHost( hiltViewModel { factory -> factory.create( address = address, - isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, showDebugOptions = BuildConfig.BUILD_TYPE != "release", navigator = navigator ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 3454dec087..c43b2c6ea9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -29,7 +29,7 @@ class LokiMessageDatabase(context: Context, helper: Provider, + val threadId: Long, +) { + constructor(changeType: ChangeType, id: MessageId, threadId: Long) + :this( + changeType = changeType, + ids = listOf(id), + threadId = threadId + ) + + enum class ChangeType { + Added, + Updated, + Deleted, + } +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 3c79d3713b..572c4bcc0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -19,12 +19,14 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import androidx.collection.MutableLongObjectMap import androidx.sqlite.db.SupportSQLiteDatabase import dagger.Lazy import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.serialization.json.Json import org.json.JSONArray -import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.sending_receiving.attachments.Attachment @@ -34,7 +36,6 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.toAddress -import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.toGroupString @@ -51,6 +52,8 @@ import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpda import org.thoughtcrime.securesms.database.model.content.MessageContent import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.preferences.CommunicationPreferences +import org.thoughtcrime.securesms.preferences.PreferenceStorage import org.thoughtcrime.securesms.pro.toProMessageBitSetValue import org.thoughtcrime.securesms.pro.toProMessageFeatures import org.thoughtcrime.securesms.pro.toProProfileBitSetValue @@ -68,18 +71,22 @@ class MmsDatabase @Inject constructor( databaseHelper: Provider, private val recipientRepository: RecipientRepository, private val json: Json, - private val threadDatabase: ThreadDatabase, private val groupReceiptDatabase: GroupReceiptDatabase, private val attachmentDatabase: AttachmentDatabase, private val reactionDatabase: ReactionDatabase, private val mmsSmsDatabase: Lazy, private val groupDatabase: GroupDatabase, - private val snodeClock: SnodeClock + private val snodeClock: SnodeClock, + private val prefs: Provider, ) : MessagingDatabase(context, databaseHelper) { private val earlyDeliveryReceiptCache = EarlyReceiptCache() private val earlyReadReceiptCache = EarlyReceiptCache() override fun getTableName() = TABLE_NAME + private val _changeNotification = MutableSharedFlow(extraBufferCapacity = 24) + + val changeNotification: SharedFlow get() = _changeNotification + fun getMessageCountForThread(threadId: Long): Int { val db = readableDatabase db.query( @@ -97,7 +104,7 @@ class MmsDatabase @Inject constructor( } fun isOutgoingMessage(id: Long): Boolean = - writableDatabase.query( + readableDatabase.query( TABLE_NAME, arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), "$ID = ?", @@ -123,13 +130,7 @@ class MmsDatabase @Inject constructor( private fun getOutgoingProFeatureCountInternal(column: String, featureMask: Long): Int { val db = readableDatabase - val outgoingTypes = MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString(",") - - // outgoing clause - val outgoingSelection = - "($MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK}) IN ($outgoingTypes)" - - val where = "($column & $featureMask) != 0 AND $outgoingSelection" + val where = "($column & $featureMask) != 0 AND $IS_OUTGOING" db.query(TABLE_NAME, arrayOf("COUNT(*)"), where, null, null, null, null).use { cursor -> if (cursor.moveToFirst()) { @@ -140,7 +141,7 @@ class MmsDatabase @Inject constructor( } fun isDeletedMessage(id: Long): Boolean = - writableDatabase.query( + readableDatabase.query( TABLE_NAME, arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), "$ID = ?", @@ -202,13 +203,17 @@ class MmsDatabase @Inject constructor( if (deliveryReceipt) GroupReceiptDatabase.STATUS_DELIVERED else GroupReceiptDatabase.STATUS_READ found = true database.execSQL( - "UPDATE " + TABLE_NAME + " SET " + - columnName + " = " + columnName + " + 1 WHERE " + ID + " = ?", - arrayOf(id.toString()) + "UPDATE $TABLE_NAME SET $columnName = $columnName + 1 WHERE $ID = ?", + arrayOf(id) ) groupReceiptDatabase .update(ourAddress, id, status, timestamp) - threadDatabase.update(threadId, false) + + _changeNotification.tryEmit(MessageChanges( + changeType = MessageChanges.ChangeType.Updated, + id = MessageId(id, true), + threadId = threadId + )) } } } @@ -228,15 +233,23 @@ class MmsDatabase @Inject constructor( } fun updateSentTimestamp(messageId: Long, newTimestamp: Long) { - val db = writableDatabase - val threadId = db.rawQuery( - "UPDATE $TABLE_NAME SET $DATE_SENT = ? WHERE $ID = ? RETURNING $THREAD_ID", - newTimestamp.toString(), - messageId.toString() - ).use { - if (it.moveToFirst()) it.getLong(0) else null + //language=roomsql + writableDatabase.query(""" + UPDATE $TABLE_NAME + SET $DATE_SENT = ?1 + WHERE $ID = ?2 AND IFNULL($DATE_SENT, 0) != ?1 + RETURNING $THREAD_ID""", + arrayOf(newTimestamp, messageId) + ).use { cursor -> + if (cursor.moveToFirst()) { + val threadId = cursor.getLong(0) + _changeNotification.tryEmit(MessageChanges( + changeType = MessageChanges.ChangeType.Updated, + id = MessageId(messageId, true), + threadId = threadId + )) + } } - } fun getThreadIdForMessage(id: Long): Long { @@ -280,33 +293,29 @@ class MmsDatabase @Inject constructor( } } - private fun updateMailboxBitmask( - id: Long, - maskOff: Long, - maskOn: Long, - threadId: Long? - ) { - val db = writableDatabase - db.execSQL( - "UPDATE " + TABLE_NAME + - " SET " + MESSAGE_BOX + " = (" + MESSAGE_BOX + " & " + (MmsSmsColumns.Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + - " WHERE " + ID + " = ?", arrayOf(id.toString() + "") - ) - - threadId?.let { threadDatabase.update(it, false) } - } - private fun markAs( messageId: Long, - baseType: Long, - threadId: Long = getThreadIdForMessage(messageId) + baseType: Long ) { - updateMailboxBitmask( - messageId, - MmsSmsColumns.Types.BASE_TYPE_MASK, - baseType, - threadId - ) + //language=roomsql + this.writableDatabase.query( + """ + UPDATE $TABLE_NAME + SET $MESSAGE_BOX = (${MESSAGE_BOX} & ${MmsSmsColumns.Types.TOTAL_MASK - MmsSmsColumns.Types.BASE_TYPE_MASK} | $baseType) + WHERE $ID = ? + RETURNING $THREAD_ID""", + arrayOf(messageId) + ).use { cursor -> + if (cursor.moveToNext()) { + _changeNotification.tryEmit( + MessageChanges( + changeType = MessageChanges.ChangeType.Updated, + id = MessageId(messageId, true), + threadId = cursor.getLong(0) + ) + ) + } + } } override fun markAsSyncing(messageId: Long) { @@ -328,7 +337,10 @@ class MmsDatabase @Inject constructor( } override fun markAsSent(messageId: Long, isSent: Boolean) { - markAs(messageId, MmsSmsColumns.Types.BASE_SENT_TYPE or if (isSent) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0) + markAs( + messageId, + MmsSmsColumns.Types.BASE_SENT_TYPE or if (isSent) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0 + ) } override fun markAsDeleted(messageId: Long, isOutgoing: Boolean, displayedMessage: String) { @@ -340,80 +352,31 @@ class MmsDatabase @Inject constructor( database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) } - val threadId = getThreadIdForMessage(messageId) - val deletedType = if (isOutgoing) { MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE} else { MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE } - markAs(messageId, deletedType, threadId) - } - override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { - val contentValues = ContentValues() - contentValues.put(EXPIRE_STARTED, startedTimestamp) - val db = writableDatabase - db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) + // We rely on the markAs to notify the change so we don't have to do it ourselves + markAs(messageId, deletedType) } - fun markAsNotified(id: Long) { - val database = writableDatabase - val contentValues = ContentValues() - contentValues.put(NOTIFIED, 1) - database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) - } - - fun setMessagesRead(threadId: Long, beforeTime: Long): List { - return setMessagesRead( - THREAD_ID + " = ? AND (" + READ + " = 0) AND " + DATE_SENT + " <= ?", - arrayOf(threadId.toString(), beforeTime.toString()) - ) - } - - fun setMessagesRead(threadId: Long): List { - return setMessagesRead( - THREAD_ID + " = ? AND (" + READ + " = 0)", - arrayOf(threadId.toString()) - ) - } - - private fun setMessagesRead(where: String, arguments: Array?): List { - val database = writableDatabase - val result: MutableList = LinkedList() - var cursor: Cursor? = null - database.beginTransaction() - try { - cursor = database.query( - TABLE_NAME, - arrayOf(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED), - where, - arguments, - null, - null, - null - ) - while (cursor != null && cursor.moveToNext()) { - if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) { - val timestamp = cursor.getLong(2) - val syncMessageId = SyncMessageId(fromSerialized(cursor.getString(1)), timestamp) - val expirationInfo = ExpirationInfo( - id = MessageId(cursor.getLong(0), mms = true), - timestamp = timestamp, - expiresIn = cursor.getLong(4), - expireStarted = cursor.getLong(5), + override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { + //language=roomsql + writableDatabase.rawQuery(""" + UPDATE $TABLE_NAME SET $EXPIRE_STARTED = ?1 + WHERE $ID = ?2 AND IFNULL($EXPIRE_STARTED, 0) != ?1 + RETURNING $THREAD_ID + """, startedTimestamp, messageId).use { cursor -> + if (cursor.moveToNext()) { + _changeNotification.tryEmit( + MessageChanges( + changeType = MessageChanges.ChangeType.Updated, + id = MessageId(messageId, true), + threadId = cursor.getLong(0) ) - result.add(MarkedMessageInfo(syncMessageId, expirationInfo)) - } + ) } - val contentValues = ContentValues() - contentValues.put(READ, 1) - contentValues.put(REACTIONS_UNREAD, 0) - database.update(TABLE_NAME, contentValues, where, arguments) - database.setTransactionSuccessful() - } finally { - cursor?.close() - database.endTransaction() } - return result } private fun getLinkPreviews( @@ -446,12 +409,11 @@ class MmsDatabase @Inject constructor( .orEmpty() } - @Throws(MmsException::class) private fun insertMessageInbox( retrieved: IncomingMediaMessage, threadId: Long, - mailbox: Long, serverTimestamp: Long, - runThreadUpdate: Boolean + mailbox: Long, + serverTimestamp: Long ): InsertResult? { if (threadId < 0 ) throw MmsException("No thread ID supplied!") if (retrieved.messageContent is DisappearingMessageUpdate) @@ -501,9 +463,13 @@ class MmsDatabase @Inject constructor( linkPreviews = retrieved.linkPreviews, contentValues = contentValues, ) - if (runThreadUpdate) { - threadDatabase.update(threadId, true) - } + + _changeNotification.tryEmit(MessageChanges( + changeType = MessageChanges.ChangeType.Added, + id = MessageId(messageId, true), + threadId = contentValues.getAsLong(THREAD_ID) + )) + return InsertResult(messageId, threadId) } @@ -511,8 +477,7 @@ class MmsDatabase @Inject constructor( fun insertSecureDecryptedMessageOutbox( retrieved: OutgoingMediaMessage, threadId: Long, - serverTimestamp: Long, - runThreadUpdate: Boolean + serverTimestamp: Long ): InsertResult? { if (threadId < 0 ) throw MmsException("No thread ID supplied!") if (retrieved.messageContent is DisappearingMessageUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) @@ -520,8 +485,7 @@ class MmsDatabase @Inject constructor( retrieved, threadId, false, - serverTimestamp, - runThreadUpdate + serverTimestamp ) if (messageId == -1L) { Log.w(TAG, "insertSecureDecryptedMessageOutbox believes the MmsDatabase insertion failed.") @@ -536,8 +500,7 @@ class MmsDatabase @Inject constructor( fun insertSecureDecryptedMessageInbox( retrieved: IncomingMediaMessage, threadId: Long, - serverTimestamp: Long = 0, - runThreadUpdate: Boolean + serverTimestamp: Long = 0 ): InsertResult? { var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT or MmsSmsColumns.Types.PUSH_MESSAGE_BIT if (retrieved.isMediaSavedDataExtraction) { @@ -546,7 +509,7 @@ class MmsDatabase @Inject constructor( if (retrieved.isMessageRequestResponse) { type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT } - return insertMessageInbox(retrieved, threadId, type, serverTimestamp, runThreadUpdate) + return insertMessageInbox(retrieved, threadId, type, serverTimestamp) } @Throws(MmsException::class) @@ -554,8 +517,7 @@ class MmsDatabase @Inject constructor( message: OutgoingMediaMessage, threadId: Long, forceSms: Boolean, - serverTimestamp: Long = 0, - runThreadUpdate: Boolean + serverTimestamp: Long = 0 ): Long { var type = MmsSmsColumns.Types.BASE_SENDING_TYPE if (message.isSecure) type = @@ -622,20 +584,16 @@ class MmsDatabase @Inject constructor( -1 ) } - with (threadDatabase) { - val lastSeen = getLastSeenAndHasSent(threadId).first() - if (lastSeen < message.sentTimeMillis) { - setLastSeen(threadId, message.sentTimeMillis) - } - setHasSent(threadId, true) - if (runThreadUpdate) { - update(threadId, true) - } - } + + _changeNotification.tryEmit(MessageChanges( + changeType = MessageChanges.ChangeType.Added, + id = MessageId(messageId, true), + threadId = threadId + )) + return messageId } - @Throws(MmsException::class) private fun insertMediaMessage( body: String?, messageContent: MessageContent?, @@ -704,48 +662,50 @@ class MmsDatabase @Inject constructor( } } - private fun doDeleteMessages( - updateThread: Boolean, - where: String, - vararg whereArgs: Any?): Boolean { - val deletedMessageIDs: MutableList - val deletedMessagesThreadIDs = hashSetOf() + private fun doDeleteMessages(where: String, vararg whereArgs: Any?): Boolean { + val deleted = mutableListOf() + val deletedByThreadIDs: MutableLongObjectMap> + //language=roomsql writableDatabase.rawQuery( "DELETE FROM $TABLE_NAME WHERE $where RETURNING $ID, $THREAD_ID", *whereArgs ).use { cursor -> - deletedMessageIDs = ArrayList(cursor.count) + deletedByThreadIDs = MutableLongObjectMap() while (cursor.moveToNext()) { - deletedMessageIDs += cursor.getLong(0) - deletedMessagesThreadIDs += cursor.getLong(1) + val threadId = cursor.getLong(1) + val messageId = MessageId(cursor.getLong(0), true) + + deletedByThreadIDs.getOrPut(threadId) { ArrayList() } += messageId + deleted += messageId.id } } // Delete messages related data from other tables - if (!deletedMessageIDs.isEmpty()) { - attachmentDatabase.deleteAttachmentsForMessages(deletedMessageIDs) - groupReceiptDatabase.deleteRowsForMessages(deletedMessageIDs) + if (deletedByThreadIDs.isNotEmpty()) { + attachmentDatabase.deleteAttachmentsForMessages(deleted) + groupReceiptDatabase.deleteRowsForMessages(deleted) notifyStickerListeners() notifyStickerPackListeners() } - if (updateThread) { - for (threadId in deletedMessagesThreadIDs) { - threadDatabase.update(threadId, false) - } + deletedByThreadIDs.forEach { threadId, deletedMessageIDs -> + _changeNotification.tryEmit(MessageChanges( + changeType = MessageChanges.ChangeType.Deleted, + ids = deletedMessageIDs, + threadId = threadId + )) } - return deletedMessageIDs.isNotEmpty() + return deleted.isNotEmpty() } override fun getTypeColumn(): String = MESSAGE_BOX override fun deleteMessage(messageId: Long) { doDeleteMessages( - updateThread = true, where = "$ID = ?", messageId ) @@ -753,18 +713,43 @@ class MmsDatabase @Inject constructor( override fun deleteMessages(messageIds: Collection) { doDeleteMessages( - updateThread = true, where = "$ID IN (SELECT value FROM json_each(?))", JSONArray(messageIds).toString() ) } override fun updateThreadId(fromId: Long, toId: Long) { - val contentValues = ContentValues(1) - contentValues.put(THREAD_ID, toId) + if (fromId == toId) { + return + } - val db = writableDatabase - db.update(SmsDatabase.TABLE_NAME, contentValues, "$THREAD_ID = ?", arrayOf("$fromId")) + //language=roomsql + val updatedMessageIDs = writableDatabase.query(""" + UPDATE $TABLE_NAME + SET $THREAD_ID = ?1 + WHERE $THREAD_ID = ?2 + RETURNING $ID + """, arrayOf(toId, fromId)).use { cursor -> + cursor.asSequence() + .map { MessageId(it.getLong(0), true) } + .toList() + } + + _changeNotification.tryEmit( + MessageChanges( + changeType = MessageChanges.ChangeType.Deleted, + ids = updatedMessageIDs, + threadId = fromId + ) + ) + + _changeNotification.tryEmit( + MessageChanges( + changeType = MessageChanges.ChangeType.Added, + ids = updatedMessageIDs, + threadId = toId + ) + ) } fun deleteThread(threadId: Long, updateThread: Boolean) { @@ -774,13 +759,11 @@ class MmsDatabase @Inject constructor( fun deleteMediaFor(threadId: Long, fromUser: String? = null) { if (fromUser != null) { doDeleteMessages( - updateThread = true, where = "$THREAD_ID = ? AND $ADDRESS = ? AND $LINK_PREVIEWS IS NULL", threadId, fromUser ) } else { doDeleteMessages( - updateThread = true, where = "$THREAD_ID = ? AND $LINK_PREVIEWS IS NULL", threadId ) @@ -789,7 +772,6 @@ class MmsDatabase @Inject constructor( fun deleteMessagesFrom(threadId: Long, fromUser: String) { // copied from deleteThreads implementation doDeleteMessages( - updateThread = true, where = "$THREAD_ID = ? AND $ADDRESS = ?", threadId, fromUser ) @@ -905,7 +887,6 @@ class MmsDatabase @Inject constructor( fun deleteThreads(threadIds: Collection, updateThread: Boolean) { doDeleteMessages( - updateThread = updateThread, where = "$THREAD_ID IN (SELECT value FROM json_each(?))", JSONArray(threadIds).toString() ) @@ -924,7 +905,6 @@ class MmsDatabase @Inject constructor( if (onlyMedia) where += " AND $PART_COUNT >= 1" doDeleteMessages( - updateThread = true, where = where, threadId ) @@ -932,30 +912,19 @@ class MmsDatabase @Inject constructor( fun readerFor(cursor: Cursor?, getQuote: Boolean = true) = Reader(cursor, getQuote) - fun setQuoteMissing(messageId: Long): Int { - val contentValues = ContentValues() - contentValues.put(QUOTE_MISSING, 1) - val database = writableDatabase - return database!!.update( - TABLE_NAME, - contentValues, - "$ID = ?", - arrayOf(messageId.toString()) - ) - } - /** * @param outgoing if true only delete outgoing messages, if false only delete incoming messages, if null delete both. */ private fun deleteExpirationTimerMessages(threadId: Long, outgoing: Boolean? = null) { - val outgoingClause = outgoing?.takeIf { ExpirationConfiguration.isNewConfigEnabled }?.let { - val comparison = if (it) "IN" else "NOT IN" - " AND $MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK} $comparison (${MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString()})" - } ?: "" + val outgoingClause = when (outgoing) { + null -> "" + true -> " AND $IS_OUTGOING" + false -> " AND NOT $IS_OUTGOING" + } val where = "$THREAD_ID = ? AND $MESSAGE_CONTENT->>'$.${MessageContent.DISCRIMINATOR}' == '${DisappearingMessageUpdate.TYPE_NAME}' " + outgoingClause - doDeleteMessages(updateThread = true, where, threadId) + doDeleteMessages(where, threadId) } object Status { @@ -992,7 +961,7 @@ class MmsDatabase @Inject constructor( cursor.getLong(cursor.getColumnIndexOrThrow(PRO_PROFILE_FEATURES)).toProProfileFeatures(this) } - if (!isReadReceiptsEnabled(context)) { + if (!prefs.get()[CommunicationPreferences.READ_RECEIPT_ENABLED]) { readReceiptCount = 0 } val recipient = getRecipientFor(address) @@ -1015,6 +984,8 @@ class MmsDatabase @Inject constructor( Log.e(TAG, "Failed to decode message content", it) }.getOrNull() + val serverHash = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.SERVER_HASH)) + return MediaMmsMessageRecord( /* id = */ id, /* conversationRecipient = */ recipient, @@ -1034,7 +1005,8 @@ class MmsDatabase @Inject constructor( /* reactions = */ reactions, /* hasMention = */ hasMention, /* messageContent = */ messageContent, - /* proFeatures = */ proFeatures + /* proFeatures = */ proFeatures, + /* serverHash = */ serverHash ) } @@ -1237,5 +1209,10 @@ class MmsDatabase @Inject constructor( db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $PRO_PROFILE_FEATURES INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $PRO_MESSAGE_FEATURES INTEGER NOT NULL DEFAULT 0") } + + fun addOutgoingColumn(db: SupportSQLiteDatabase) { + val outgoingTypeSet = MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString(separator = ",", prefix = "(", postfix = ")") + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $IS_OUTGOING BOOLEAN GENERATED ALWAYS AS (($MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK}) IN ${outgoingTypeSet}) VIRTUAL") + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 5decc3301d..42cbfdcda5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -7,6 +7,12 @@ public interface MmsSmsColumns { public static final String NORMALIZED_DATE_SENT = "date_sent"; public static final String NORMALIZED_DATE_RECEIVED = "date_received"; public static final String THREAD_ID = "thread_id"; + + // Read status of this message - this piece of data is no longer used and will be removed in the + // future. + // To determine if a message is read, compare the message's time with the thread's lastSeen + // time. + @Deprecated(forRemoval = true) public static final String READ = "read"; public static final String BODY = "body"; public static final String MESSAGE_CONTENT = "message_content"; @@ -46,6 +52,8 @@ public interface MmsSmsColumns { public static final String PRO_MESSAGE_FEATURES = "pro_message_features"; public static final String PRO_PROFILE_FEATURES = "pro_profile_features"; + public static final String IS_OUTGOING = "is_outgoing"; + public static class Types { protected static final long TOTAL_MASK = 0xFFFFFFFF; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 2293988d0c..0a4d1e75ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -18,6 +18,7 @@ import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX; import static org.thoughtcrime.securesms.database.MmsSmsColumns.ID; +import static org.thoughtcrime.securesms.database.MmsSmsColumns.IS_OUTGOING; import static org.thoughtcrime.securesms.database.MmsSmsColumns.NOTIFIED; import static org.thoughtcrime.securesms.database.MmsSmsColumns.READ; import static org.thoughtcrime.securesms.database.MmsSmsColumns.THREAD_ID; @@ -32,6 +33,7 @@ import org.jspecify.annotations.Nullable; import org.session.libsession.messaging.utilities.UpdateMessageData; import org.session.libsession.utilities.Address; +import org.session.libsession.utilities.ConfigFactoryProtocol; import org.session.libsession.utilities.GroupUtil; import org.session.libsignal.utilities.AccountId; import org.session.libsignal.utilities.Log; @@ -43,6 +45,7 @@ import java.io.Closeable; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -55,6 +58,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext; import kotlin.Pair; import kotlin.Triple; +import kotlin.collections.CollectionsKt; +import kotlinx.serialization.json.Json; @Singleton public class MmsSmsDatabase extends Database { @@ -66,12 +71,14 @@ public class MmsSmsDatabase extends Database { public static final String MMS_TRANSPORT = "mms"; public static final String SMS_TRANSPORT = "sms"; - private static final String PROJECTION_ALL = "*"; + static final String PROJECTION_ALL = "*"; private final LoginStateRepository loginStateRepository; private final Lazy<@NonNull ThreadDatabase> threadDatabase; private final Lazy<@NonNull MmsDatabase> mmsDatabase; private final Lazy<@NonNull SmsDatabase> smsDatabase; + final Lazy<@NonNull ConfigFactoryProtocol> configFactory; + @NonNull final Json json; @Inject public MmsSmsDatabase(@ApplicationContext Context context, @@ -79,36 +86,31 @@ public MmsSmsDatabase(@ApplicationContext Context context, LoginStateRepository loginStateRepository, Lazy<@NonNull ThreadDatabase> threadDatabase, Lazy<@NonNull MmsDatabase> mmsDatabase, - Lazy<@NonNull SmsDatabase> smsDatabase) { + Lazy<@NonNull SmsDatabase> smsDatabase, + Lazy<@NonNull ConfigFactoryProtocol> configFactory, + @NonNull Json json) { super(context, databaseHelper); this.loginStateRepository = loginStateRepository; this.threadDatabase = threadDatabase; this.mmsDatabase = mmsDatabase; this.smsDatabase = smsDatabase; + this.configFactory = configFactory; + this.json = json; } public @Nullable MessageRecord getMessageForTimestamp(long threadId, long timestamp) { final String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor); return reader.getNext(); } } public @Nullable MessageRecord getMessageById(@NonNull MessageId id) { - String selection = ID + " = " + id.getId() + " AND " + - TRANSPORT + " = '" + (id.isMms() ? MMS_TRANSPORT : SMS_TRANSPORT) + "'"; - try (MmsSmsDatabase.Reader reader = readerFor(queryTables(PROJECTION_ALL, selection, true, null, null, null))) { - final MessageRecord messageRecord; - if ((messageRecord = reader.getNext()) != null) { - return messageRecord; - } - } - - return null; + return CollectionsKt.firstOrNull(MmsSmsDatabaseExt.INSTANCE.getMessages(this, Collections.singletonList(id), false)); } public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, String serializedAuthor) { @@ -119,7 +121,7 @@ public MmsSmsDatabase(@ApplicationContext Context context, String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; @@ -142,7 +144,7 @@ public MmsSmsDatabase(@ApplicationContext Context context, */ @Deprecated(forRemoval = true) public @Nullable MessageRecord getMessageByTimestamp(long timestamp, String serializedAuthor, boolean getQuote) { - try (Cursor cursor = queryTables(PROJECTION_ALL, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, true, null, null, null)) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; @@ -165,7 +167,7 @@ public MessageId getLastSentMessageID(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND NOT " + MmsSmsColumns.IS_DELETED; - try (final Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, order, null)) { + try (final Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, order, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -188,27 +190,20 @@ public Cursor getConversation(long threadId, boolean reverse, long offset, long String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + THREAD_ID + " != " + -1L; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; - return queryTables(PROJECTION_ALL, selection, true, null, order, limitStr); + return MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, order, limitStr); } public Cursor getConversation(long threadId, boolean reverse) { return getConversation(threadId, reverse, 0, 0); } - public Cursor getConversationSnippet(long threadId) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - - return queryTables(PROJECTION_ALL, selection, true, null, order, null); - } - public List getRecentChatMemberAddresses(long threadId, int limit) { String projection = "DISTINCT " + MmsSmsColumns.ADDRESS; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = String.valueOf(limit); - try (Cursor cursor = queryTables(projection, selection, true, null, order, limitStr)) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, projection, selection, true, null, order, limitStr)) { List addresses = new ArrayList<>(); while (cursor != null && cursor.moveToNext()) { String address = cursor.getString(0); @@ -245,7 +240,7 @@ public Set getAllMessageRecordsFromSenderInThread(long threadId, Set identifiedMessages = new HashSet(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -261,7 +256,7 @@ public List> getAllMessageRecordsBefore(long threadI List> identifiedMessages = new ArrayList<>(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null)) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -280,7 +275,7 @@ public List> getAllMessagesWithHash(long threadId) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; List> identifiedMessages = new ArrayList<>(); - try (Cursor cursor = queryTables(PROJECTION_ALL, selection, true, null, null, null); + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, null, null); MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord record; @@ -304,7 +299,7 @@ public MessageRecord getLastMessage(long threadId, boolean includeReactions, boo String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + "NOT " + MmsSmsColumns.IS_DELETED; - try (Cursor cursor = queryTables(PROJECTION_ALL, selection, includeReactions, null, order, "1")) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, includeReactions, null, order, "1")) { return readerFor(cursor, getQuote).getNext(); } } @@ -321,7 +316,7 @@ public MessageRecord getLastMessage(long threadId, boolean includeReactions, boo */ @Nullable public Pair getMaxTimestampInThreadUpTo(@NonNull final MessageId messageId) { - Pair query = MmsSmsDatabaseSQLKt.buildMaxTimestampInThreadUpToQuery(messageId); + Pair query = MmsSmsDatabaseExt.INSTANCE.buildMaxTimestampInThreadUpToQuery(messageId); try (Cursor cursor = getReadableDatabase().rawQuery(query.getFirst(), query.getSecond())) { if (cursor != null && cursor.moveToFirst()) { return new Pair<>(cursor.getLong(0), cursor.getLong(1)); @@ -331,24 +326,14 @@ public Pair getMaxTimestampInThreadUpTo(@NonNull final MessageId mes } } - private String buildOutgoingConditionForNotifications() { - return "(" + TRANSPORT + " = '" + MMS_TRANSPORT + "' AND " + - "(" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + buildOutgoingTypesList() + "))" + - " OR " + - "(" + TRANSPORT + " = '" + SMS_TRANSPORT + "' AND " + - "(" + SmsDatabase.TYPE + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + buildOutgoingTypesList() + "))"; - } - public Cursor getUnreadIncomingForNotifications(int maxRows) { - String outgoing = buildOutgoingConditionForNotifications(); - String selection = "(" + READ + " = 0 AND " + NOTIFIED + " = 0 AND NOT (" + outgoing + "))"; + String selection = "(" + READ + " = 0 AND " + NOTIFIED + " = 0 AND NOT (" + IS_OUTGOING + "))"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION_ALL, selection, true, null, order, limitStr); + return MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, selection, true, null, order, limitStr); } public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { - String outgoing = buildOutgoingConditionForNotifications(); String lastSeenQuery = "SELECT " + ThreadDatabase.LAST_SEEN + " FROM " + ThreadDatabase.TABLE_NAME + @@ -359,7 +344,7 @@ public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION_ALL, outgoing, true, reactionSelection, order, limitStr); + return MmsSmsDatabaseExt.INSTANCE.queryTables(this, PROJECTION_ALL, IS_OUTGOING, true, reactionSelection, order, limitStr); } public Set
getAllReferencedAddresses() { @@ -368,7 +353,7 @@ public Set
getAllReferencedAddresses() { " AND " + MmsSmsColumns.ADDRESS + " != ''"; Set
out = new HashSet<>(); - try (Cursor cursor = queryTables(projection, selection, true, null, null, null)) { + try (Cursor cursor = MmsSmsDatabaseExt.INSTANCE.queryTables(this, projection, selection, true, null, null, null)) { while (cursor != null && cursor.moveToNext()) { String serialized = cursor.getString(0); try { @@ -382,25 +367,6 @@ public Set
getAllReferencedAddresses() { return out; } - /** Builds the comma-separated list of base types that represent - * *outgoing* messages (same helper as before). */ - private String buildOutgoingTypesList() { - long[] types = MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES; - StringBuilder sb = new StringBuilder(types.length * 3); - for (int i = 0; i < types.length; i++) { - if (i > 0) sb.append(','); - sb.append(types[i]); - } - return sb.toString(); - } - - public int getUnreadCount(long threadId) { - String selection = READ + " = 0 AND " + NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.THREAD_ID + " != " + -1L; - - try (Cursor cursor = queryTables(ID, selection, true, null, null, null)) { - return cursor != null ? cursor.getCount() : 0; - } - } public void deleteGroupInfoMessage(AccountId groupId, Class kind) { long threadId = threadDatabase.get().getThreadIdIfExistsFor(groupId.getHexString()); @@ -572,22 +538,6 @@ public static void migrateLegacyCommunityAddresses2(final SQLiteDatabase db) { migrateLegacyCommunityAddresses2(db, MmsDatabase.TABLE_NAME); } - private Cursor queryTables( - @NonNull String projection, - @Nullable String selection, - boolean includeReactions, - @Nullable String additionalReactionSelection, - @Nullable String order, - @Nullable String limit) { - SQLiteDatabase db = getReadableDatabase(); - String query = MmsSmsDatabaseSQLKt.buildMmsSmsCombinedQuery(projection, - selection, - includeReactions, - additionalReactionSelection, - order, - limit); - return db.rawQuery(query, null); - } public Reader readerFor(@NonNull Cursor cursor) { return readerFor(cursor, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseExt.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseExt.kt new file mode 100644 index 0000000000..c3f3b51a87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseExt.kt @@ -0,0 +1,430 @@ +package org.thoughtcrime.securesms.database + +import android.database.Cursor +import net.zetetic.database.sqlcipher.SQLiteDatabase +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.withUserConfigs +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.util.asSequence +import org.thoughtcrime.securesms.util.get + +object MmsSmsDatabaseExt { + // The query parts that fetch all reactions for a given message, and group them into a JSON array + //language=roomsql + private const val REACTIONS_QUERY_PARTS = """ + SELECT json_group_array( + json_object( + '${ReactionDatabase.ROW_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.ROW_ID}, + '${ReactionDatabase.MESSAGE_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID}, + '${ReactionDatabase.IS_MMS}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS}, + '${ReactionDatabase.AUTHOR_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.AUTHOR_ID}, + '${ReactionDatabase.EMOJI}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.EMOJI}, + '${ReactionDatabase.SERVER_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SERVER_ID}, + '${ReactionDatabase.COUNT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.COUNT}, + '${ReactionDatabase.SORT_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SORT_ID}, + '${ReactionDatabase.DATE_SENT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_SENT}, + '${ReactionDatabase.DATE_RECEIVED}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_RECEIVED} + ) + ) + FROM ${ReactionDatabase.TABLE_NAME} + """ + + // Subquery to grab sms' server hash + //language=roomsql + private const val SMS_HASH_QUERY = """ + SELECT server_hash + FROM ${LokiMessageDatabase.smsHashTable} sms_hash + WHERE sms_hash.message_id = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // Subquery to grab mms' server hash + //language=roomsql + private const val MMS_HASH_QUERY = """ + SELECT server_hash + FROM ${LokiMessageDatabase.mmsHashTable} mms_hash + WHERE mms_hash.message_id = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + /** + * Build a combined query to fetch both MMS and SMS messages in one go, the high level idea is to + * use a UNION between two SELECT statements, one for MMS and one for SMS. And they will need + * to have the same projection so we'll also do some aliasing on them. This can be illustrated as: + * + * For each message, we will perform sub-query to reaction/attachment database to query the relevant + * data. We try not to use JOIN as they screw up performance by impacting the index selection. + * + * ```sqlite + * SELECT sms_fields, + * (query reaction table) AS reactions, + * NULL AS attachments, + * (query hash table) AS server_hash + * FROM sms + * + * UNION ALL + * + * SELECT + * mms_fields, + * (query reaction table) AS reactions, + * (query attachment table) AS attachments, + * (query hash table) AS server_hash + * FROM mms + * ``` + */ + private fun buildMmsSmsCombinedQuery( + projection: String, + selection: String?, + includeReactions: Boolean, + reactionSelection: String?, + order: String?, + limit: String?, + querySms: Boolean = true, + queryMms: Boolean = true, + ): String { + require(querySms || queryMms) { + "At least one of querySms or queryMms must be true" + } + + // Custom where statement for reactions if provided + val additionalReactionSelection = reactionSelection?.let { " AND ($it)" }.orEmpty() + + // If reactions are not requested, we just return an empty JSON array + val smsReactionQuery = if (includeReactions) { + """($REACTIONS_QUERY_PARTS + WHERE + ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + AND NOT ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} + $additionalReactionSelection)""" + } else { + "'[]'" + } + + val whereStatement = selection?.let { "WHERE $it" }.orEmpty() + + // The main query for SMS messages + val smsQuery = if (querySms) """ + SELECT + ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, + ${SmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, + ${MmsSmsColumns.ID}, + 'SMS::' || ${MmsSmsColumns.ID} || '::' || ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, + NULL AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, + $smsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, + ${SmsDatabase.BODY}, + NULL AS ${MmsSmsColumns.MESSAGE_CONTENT}, + ${MmsSmsColumns.READ}, + ${MmsSmsColumns.THREAD_ID}, + ${SmsDatabase.TYPE}, + ${SmsDatabase.ADDRESS}, + NULL AS ${MmsDatabase.MESSAGE_TYPE}, + NULL AS ${MmsDatabase.MESSAGE_BOX}, + ${SmsDatabase.STATUS}, + NULL AS ${MmsDatabase.MESSAGE_SIZE}, + NULL AS ${MmsDatabase.EXPIRY}, + NULL AS ${MmsDatabase.STATUS}, + ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, + ${MmsSmsColumns.READ_RECEIPT_COUNT}, + ${MmsSmsColumns.EXPIRES_IN}, + ${MmsSmsColumns.EXPIRE_STARTED}, + ${MmsSmsColumns.NOTIFIED}, + '${MmsSmsDatabase.SMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, + NULL AS ${MmsDatabase.QUOTE_ID}, + NULL AS ${MmsDatabase.QUOTE_AUTHOR}, + NULL AS ${MmsDatabase.QUOTE_BODY}, + NULL AS ${MmsDatabase.QUOTE_MISSING}, + NULL AS ${MmsDatabase.QUOTE_ATTACHMENT}, + NULL AS ${MmsDatabase.LINK_PREVIEWS}, + ${MmsSmsColumns.HAS_MENTION}, + ($SMS_HASH_QUERY) AS ${MmsSmsColumns.SERVER_HASH}, + ${MmsSmsColumns.PRO_MESSAGE_FEATURES}, + ${MmsSmsColumns.PRO_PROFILE_FEATURES}, + ${MmsSmsColumns.IS_OUTGOING} + FROM ${SmsDatabase.TABLE_NAME} + $whereStatement + """ else null + + // The subquery that fetches all attachments for a given MMS message, and group them into a JSON array + val attachmentQuery = """ + SELECT json_group_array( + json_object( + '${AttachmentDatabase.ROW_ID}', a.${AttachmentDatabase.ROW_ID}, + '${AttachmentDatabase.UNIQUE_ID}', a.${AttachmentDatabase.UNIQUE_ID}, + '${AttachmentDatabase.MMS_ID}', a.${AttachmentDatabase.MMS_ID}, + '${AttachmentDatabase.SIZE}', a.${AttachmentDatabase.SIZE}, + '${AttachmentDatabase.FILE_NAME}', a.${AttachmentDatabase.FILE_NAME}, + '${AttachmentDatabase.DATA}', a.${AttachmentDatabase.DATA}, + '${AttachmentDatabase.THUMBNAIL}', a.${AttachmentDatabase.THUMBNAIL}, + '${AttachmentDatabase.CONTENT_TYPE}', a.${AttachmentDatabase.CONTENT_TYPE}, + '${AttachmentDatabase.CONTENT_LOCATION}', a.${AttachmentDatabase.CONTENT_LOCATION}, + '${AttachmentDatabase.FAST_PREFLIGHT_ID}', a.${AttachmentDatabase.FAST_PREFLIGHT_ID}, + '${AttachmentDatabase.VOICE_NOTE}', a.${AttachmentDatabase.VOICE_NOTE}, + '${AttachmentDatabase.WIDTH}', a.${AttachmentDatabase.WIDTH}, + '${AttachmentDatabase.HEIGHT}', a.${AttachmentDatabase.HEIGHT}, + '${AttachmentDatabase.QUOTE}', a.${AttachmentDatabase.QUOTE}, + '${AttachmentDatabase.CONTENT_DISPOSITION}', a.${AttachmentDatabase.CONTENT_DISPOSITION}, + '${AttachmentDatabase.NAME}', a.${AttachmentDatabase.NAME}, + '${AttachmentDatabase.TRANSFER_STATE}', a.${AttachmentDatabase.TRANSFER_STATE}, + '${AttachmentDatabase.CAPTION}', a.${AttachmentDatabase.CAPTION}, + '${AttachmentDatabase.STICKER_PACK_ID}', a.${AttachmentDatabase.STICKER_PACK_ID}, + '${AttachmentDatabase.STICKER_PACK_KEY}', a.${AttachmentDatabase.STICKER_PACK_KEY}, + '${AttachmentDatabase.AUDIO_DURATION}', ifnull(a.${AttachmentDatabase.AUDIO_DURATION}, -1), + '${AttachmentDatabase.STICKER_ID}', a.${AttachmentDatabase.STICKER_ID} + ) + ) + FROM ${AttachmentDatabase.TABLE_NAME} AS a + WHERE a.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // Custom where statement for reactions if provided + val mmsReactionQuery = if (includeReactions) { + """($REACTIONS_QUERY_PARTS + WHERE + ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + AND ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} + $additionalReactionSelection)""" + } else { + "'[]'" + } + + // The main query for MMS messages + val mmsQuery = if (queryMms) """ + SELECT + ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, + ${MmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, + ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} AS ${MmsSmsColumns.ID}, + 'MMS::' || ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} || '::' || ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, + ($attachmentQuery) AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, + $mmsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, + ${MmsSmsColumns.BODY}, + ${MmsSmsColumns.MESSAGE_CONTENT}, + ${MmsSmsColumns.READ}, + ${MmsSmsColumns.THREAD_ID}, + NULL AS ${SmsDatabase.TYPE}, + ${MmsSmsColumns.ADDRESS}, + ${MmsDatabase.MESSAGE_TYPE}, + ${MmsDatabase.MESSAGE_BOX}, + NULL AS ${SmsDatabase.STATUS}, + ${MmsDatabase.MESSAGE_SIZE}, + ${MmsDatabase.EXPIRY}, + ${MmsDatabase.STATUS}, + ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, + ${MmsSmsColumns.READ_RECEIPT_COUNT}, + ${MmsSmsColumns.EXPIRES_IN}, + ${MmsSmsColumns.EXPIRE_STARTED}, + ${MmsSmsColumns.NOTIFIED}, + '${MmsSmsDatabase.MMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, + ${MmsDatabase.QUOTE_ID}, + ${MmsDatabase.QUOTE_AUTHOR}, + ${MmsDatabase.QUOTE_BODY}, + ${MmsDatabase.QUOTE_MISSING}, + ${MmsDatabase.QUOTE_ATTACHMENT}, + ${MmsDatabase.LINK_PREVIEWS}, + ${MmsSmsColumns.HAS_MENTION}, + ($MMS_HASH_QUERY) AS ${MmsSmsColumns.SERVER_HASH}, + ${MmsSmsColumns.PRO_MESSAGE_FEATURES}, + ${MmsSmsColumns.PRO_PROFILE_FEATURES}, + ${MmsSmsColumns.IS_OUTGOING} + FROM ${MmsDatabase.TABLE_NAME} + $whereStatement + """ else null + + val orderStatement = order?.let { "ORDER BY $it" }.orEmpty() + val limitStatement = limit?.let { "LIMIT $it" }.orEmpty() + + val cteQuery = when { + smsQuery != null && mmsQuery != null -> "WITH combined AS ($smsQuery UNION ALL $mmsQuery)" + else -> "WITH combined AS (${smsQuery ?: mmsQuery})" + } + + return """ + $cteQuery + + SELECT $projection + FROM combined + $orderStatement + $limitStatement + """ + } + + @JvmOverloads + fun MmsSmsDatabase.queryTables( + projection: String, + selection: String?, + includeReactions: Boolean, + additionalReactionSelection: String?, + order: String?, + limit: String?, + querySms: Boolean = true, + queryMms: Boolean = true, + ): Cursor { + val query = buildMmsSmsCombinedQuery( + projection = projection, + selection = selection, + includeReactions = includeReactions, + reactionSelection = additionalReactionSelection, + order = order, + limit = limit, + querySms = querySms, + queryMms = queryMms + ) + return readableDatabase.rawQuery(query, null) + } + + /** + * Build a query to get the maximum timestamp (date sent) in a thread up to and including + * the timestamp of the given message ID. + * + * This query will also look at reactions associated with messages in the thread + * to ensure that if there are reactions with later timestamps, they are considered + * as well. + * + * @return A pair containing the SQL query string and an array of parameters to bind. + * The query will return at most one row of "maxTimestamp", "threadId". + */ + fun buildMaxTimestampInThreadUpToQuery(id: MessageId): Pair> { + val msgTable = if (id.mms) MmsDatabase.TABLE_NAME else SmsDatabase.TABLE_NAME + val dateSentColumn = if (id.mms) MmsDatabase.DATE_SENT else SmsDatabase.DATE_SENT + val threadIdColumn = if (id.mms) MmsSmsColumns.THREAD_ID else SmsDatabase.THREAD_ID + + // The query below does this: + // 1. Query the given message, find out its thread id and its date sent + // 2. Find all the messages in this thread before this messages (using result from step 1) + // 3. With this message + earlier messages, grab all the reactions associated with them + // 4. Look at the max date among the reactions returned from step 3 + // 5. Return the max between this message's date and the max reaction date + //language=roomsql + return """ + SELECT + MAX( + mainMessage.$dateSentColumn, + IFNULL( + ( + SELECT MAX(r.${ReactionDatabase.DATE_SENT}) + FROM ${ReactionDatabase.TABLE_NAME} r + INDEXED BY reaction_message_id_is_mms_index + WHERE (r.${ReactionDatabase.MESSAGE_ID}, r.${ReactionDatabase.IS_MMS}) IN ( + SELECT s.${MmsSmsColumns.ID}, FALSE + FROM ${SmsDatabase.TABLE_NAME} s + WHERE s.${SmsDatabase.THREAD_ID} = mainMessage.${threadIdColumn} AND + s.${SmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn + + UNION ALL + + SELECT m.${MmsSmsColumns.ID}, TRUE + FROM ${MmsDatabase.TABLE_NAME} m + WHERE m.${MmsSmsColumns.THREAD_ID} = mainMessage.${threadIdColumn} AND + m.${MmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn + ) + ), + 0 + ) + ) AS maxTimestamp, + mainMessage.$threadIdColumn AS threadId + FROM $msgTable mainMessage + WHERE mainMessage.${MmsSmsColumns.ID} = ? + """ to arrayOf(id.id) + } + + fun MmsSmsDatabase.getUnreadCount(address: Address.Conversable): Int { + val lastRead = configFactory.get().withUserConfigs { it.convoInfoVolatile.get(address) } + ?.lastRead ?: 0L + + //language=roomsql + return readableDatabase.rawQuery(""" + SELECT IFNULL( + ( + SELECT COUNT(*) + FROM ${SmsDatabase.TABLE_NAME} s + WHERE s.${SmsDatabase.THREAD_ID} = ( + SELECT threads.${ThreadDatabase.ID} + FROM ${ThreadDatabase.TABLE_NAME} AS threads + WHERE threads.${ThreadDatabase.ADDRESS} = ?1 + ) + AND s.${SmsDatabase.DATE_SENT} > ?2 + AND NOT s.${MmsSmsColumns.IS_OUTGOING} + AND NOT s.${MmsSmsColumns.IS_DELETED} + ), 0) + IFNULL(( + SELECT COUNT(*) + FROM ${MmsDatabase.TABLE_NAME} m + WHERE m.${MmsSmsColumns.THREAD_ID} = ( + SELECT threads.${ThreadDatabase.ID} + FROM ${ThreadDatabase.TABLE_NAME} AS threads + WHERE threads.${ThreadDatabase.ADDRESS} = ?1 + ) + AND m.${MmsDatabase.DATE_SENT} > ?2 + AND NOT m.${MmsSmsColumns.IS_OUTGOING} + AND NOT m.${MmsSmsColumns.IS_DELETED} + ), 0) + """, address.address, lastRead).use { cursor -> + if (cursor.moveToFirst()) { + cursor.getInt(0) + } else { + 0 + } + } + } + + /** + * Find all incoming messages (including control messages) for the given thread within + * a time range. + */ + fun MmsSmsDatabase.findIncomingMessages( + threadId: Long, + startMsExclusive: Long, + endMsInclusive: Long + ): List { + return queryTables( + projection = MmsSmsDatabase.PROJECTION_ALL, + selection = "${MmsSmsColumns.THREAD_ID} = $threadId AND ${MmsSmsColumns.NORMALIZED_DATE_SENT} > $startMsExclusive AND ${MmsSmsColumns.NORMALIZED_DATE_SENT} <= $endMsInclusive", + includeReactions = false, + additionalReactionSelection = null, + order = null, + limit = null, + ).use { + val reader = readerFor(it) + generateSequence { reader.next }.toList() + } + } + + fun MmsSmsDatabase.getMessages(messageIds: List, includeReactions: Boolean = false): List { + val records = ArrayList(messageIds.size) + + if (messageIds.any { it.sms }) { + val idSet = messageIds.asSequence().filter { it.sms }.joinToString(separator = ",") { it.id.toString() } + + queryTables( + projection = MmsSmsDatabase.PROJECTION_ALL, + selection = """${MmsSmsColumns.ID} IN ($idSet)""", + includeReactions = includeReactions, + additionalReactionSelection = null, + order = null, + limit = null, + queryMms = false, + querySms = true, + ).use { cursor -> + val reader = readerFor(cursor) + records.addAll(generateSequence { reader.next }) + } + } + + if (messageIds.any { it.mms }) { + val idSet = messageIds.asSequence().filter { it.mms }.joinToString(separator = ",") { it.id.toString() } + + queryTables( + projection = MmsSmsDatabase.PROJECTION_ALL, + selection = """${MmsSmsColumns.ID} IN ($idSet)""", + includeReactions = includeReactions, + additionalReactionSelection = null, + order = null, + limit = null, + querySms = false, + queryMms = true, + ).use { cursor -> + val reader = readerFor(cursor) + records.addAll(generateSequence { reader.next }) + } + } + + return records + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt deleted file mode 100644 index e4682ee9e6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt +++ /dev/null @@ -1,281 +0,0 @@ -package org.thoughtcrime.securesms.database - -import org.thoughtcrime.securesms.database.model.MessageId - -/** - * Build a combined query to fetch both MMS and SMS messages in one go, the high level idea is to - * use a UNION between two SELECT statements, one for MMS and one for SMS. And they will need - * to have the same projection so we'll also do some aliasing on them. This can be illustrated as: - * - * For each message, we will perform sub-query to reaction/attachment database to query the relevant - * data. We try not to use JOIN as they screw up performance by impacting the index selection. - * - * ```sqlite - * SELECT sms_fields, - * (query reaction table) AS reactions, - * NULL AS attachments, - * (query hash table) AS server_hash - * FROM sms - * - * UNION ALL - * - * SELECT - * mms_fields, - * (query reaction table) AS reactions, - * (query attachment table) AS attachments, - * (query hash table) AS server_hash - * FROM mms - * ``` - */ -fun buildMmsSmsCombinedQuery( - projection: String, - selection: String?, - includeReactions: Boolean, - reactionSelection: String?, - order: String?, - limit: String? -): String { - // The query parts that fetch all reactions for a given message, and group them into a JSON array - val reactionsQueryParts = """ - SELECT json_group_array( - json_object( - '${ReactionDatabase.ROW_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.ROW_ID}, - '${ReactionDatabase.MESSAGE_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID}, - '${ReactionDatabase.IS_MMS}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS}, - '${ReactionDatabase.AUTHOR_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.AUTHOR_ID}, - '${ReactionDatabase.EMOJI}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.EMOJI}, - '${ReactionDatabase.SERVER_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SERVER_ID}, - '${ReactionDatabase.COUNT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.COUNT}, - '${ReactionDatabase.SORT_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SORT_ID}, - '${ReactionDatabase.DATE_SENT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_SENT}, - '${ReactionDatabase.DATE_RECEIVED}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_RECEIVED} - ) - ) - FROM ${ReactionDatabase.TABLE_NAME} - """ - - // Subquery to grab sms' server hash - val smsHashQuery = """ - SELECT server_hash - FROM ${LokiMessageDatabase.smsHashTable} sms_hash - WHERE sms_hash.message_id = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} - """ - - // Custom where statement for reactions if provided - val additionalReactionSelection = reactionSelection?.let { " AND ($it)" }.orEmpty() - - // If reactions are not requested, we just return an empty JSON array - val smsReactionQuery = if (includeReactions) { - """($reactionsQueryParts - WHERE - ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} - AND NOT ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} - $additionalReactionSelection)""" - } else { - "'[]'" - } - - val whereStatement = selection?.let { "WHERE $it" }.orEmpty() - - // The main query for SMS messages - val smsQuery = """ - SELECT - ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, - ${SmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, - ${MmsSmsColumns.ID}, - 'SMS::' || ${MmsSmsColumns.ID} || '::' || ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, - NULL AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, - $smsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, - ${SmsDatabase.BODY}, - NULL AS ${MmsSmsColumns.MESSAGE_CONTENT}, - ${MmsSmsColumns.READ}, - ${MmsSmsColumns.THREAD_ID}, - ${SmsDatabase.TYPE}, - ${SmsDatabase.ADDRESS}, - NULL AS ${MmsDatabase.MESSAGE_TYPE}, - NULL AS ${MmsDatabase.MESSAGE_BOX}, - ${SmsDatabase.STATUS}, - NULL AS ${MmsDatabase.MESSAGE_SIZE}, - NULL AS ${MmsDatabase.EXPIRY}, - NULL AS ${MmsDatabase.STATUS}, - ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, - ${MmsSmsColumns.READ_RECEIPT_COUNT}, - ${MmsSmsColumns.EXPIRES_IN}, - ${MmsSmsColumns.EXPIRE_STARTED}, - ${MmsSmsColumns.NOTIFIED}, - '${MmsSmsDatabase.SMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, - NULL AS ${MmsDatabase.QUOTE_ID}, - NULL AS ${MmsDatabase.QUOTE_AUTHOR}, - NULL AS ${MmsDatabase.QUOTE_BODY}, - NULL AS ${MmsDatabase.QUOTE_MISSING}, - NULL AS ${MmsDatabase.QUOTE_ATTACHMENT}, - NULL AS ${MmsDatabase.LINK_PREVIEWS}, - ${MmsSmsColumns.HAS_MENTION}, - ($smsHashQuery) AS ${MmsSmsColumns.SERVER_HASH}, - ${MmsSmsColumns.PRO_MESSAGE_FEATURES}, - ${MmsSmsColumns.PRO_PROFILE_FEATURES} - FROM ${SmsDatabase.TABLE_NAME} - $whereStatement - """ - - // Subquery to grab mms' server hash - val mmsHashQuery = """ - SELECT server_hash - FROM ${LokiMessageDatabase.mmsHashTable} mms_hash - WHERE mms_hash.message_id = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} - """ - - // The subquery that fetches all attachments for a given MMS message, and group them into a JSON array - val attachmentQuery = """ - SELECT json_group_array( - json_object( - '${AttachmentDatabase.ROW_ID}', a.${AttachmentDatabase.ROW_ID}, - '${AttachmentDatabase.UNIQUE_ID}', a.${AttachmentDatabase.UNIQUE_ID}, - '${AttachmentDatabase.MMS_ID}', a.${AttachmentDatabase.MMS_ID}, - '${AttachmentDatabase.SIZE}', a.${AttachmentDatabase.SIZE}, - '${AttachmentDatabase.FILE_NAME}', a.${AttachmentDatabase.FILE_NAME}, - '${AttachmentDatabase.DATA}', a.${AttachmentDatabase.DATA}, - '${AttachmentDatabase.THUMBNAIL}', a.${AttachmentDatabase.THUMBNAIL}, - '${AttachmentDatabase.CONTENT_TYPE}', a.${AttachmentDatabase.CONTENT_TYPE}, - '${AttachmentDatabase.CONTENT_LOCATION}', a.${AttachmentDatabase.CONTENT_LOCATION}, - '${AttachmentDatabase.FAST_PREFLIGHT_ID}', a.${AttachmentDatabase.FAST_PREFLIGHT_ID}, - '${AttachmentDatabase.VOICE_NOTE}', a.${AttachmentDatabase.VOICE_NOTE}, - '${AttachmentDatabase.WIDTH}', a.${AttachmentDatabase.WIDTH}, - '${AttachmentDatabase.HEIGHT}', a.${AttachmentDatabase.HEIGHT}, - '${AttachmentDatabase.QUOTE}', a.${AttachmentDatabase.QUOTE}, - '${AttachmentDatabase.CONTENT_DISPOSITION}', a.${AttachmentDatabase.CONTENT_DISPOSITION}, - '${AttachmentDatabase.NAME}', a.${AttachmentDatabase.NAME}, - '${AttachmentDatabase.TRANSFER_STATE}', a.${AttachmentDatabase.TRANSFER_STATE}, - '${AttachmentDatabase.CAPTION}', a.${AttachmentDatabase.CAPTION}, - '${AttachmentDatabase.STICKER_PACK_ID}', a.${AttachmentDatabase.STICKER_PACK_ID}, - '${AttachmentDatabase.STICKER_PACK_KEY}', a.${AttachmentDatabase.STICKER_PACK_KEY}, - '${AttachmentDatabase.AUDIO_DURATION}', ifnull(a.${AttachmentDatabase.AUDIO_DURATION}, -1), - '${AttachmentDatabase.STICKER_ID}', a.${AttachmentDatabase.STICKER_ID} - ) - ) - FROM ${AttachmentDatabase.TABLE_NAME} AS a - WHERE a.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} - """ - - // Custom where statement for reactions if provided - val mmsReactionQuery = if (includeReactions) { - """($reactionsQueryParts - WHERE - ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} - AND ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} - $additionalReactionSelection)""" - } else { - "'[]'" - } - - // The main query for MMS messages - val mmsQuery = """ - SELECT - ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, - ${MmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, - ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} AS ${MmsSmsColumns.ID}, - 'MMS::' || ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} || '::' || ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, - ($attachmentQuery) AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, - $mmsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, - ${MmsSmsColumns.BODY}, - ${MmsSmsColumns.MESSAGE_CONTENT}, - ${MmsSmsColumns.READ}, - ${MmsSmsColumns.THREAD_ID}, - NULL AS ${SmsDatabase.TYPE}, - ${MmsSmsColumns.ADDRESS}, - ${MmsDatabase.MESSAGE_TYPE}, - ${MmsDatabase.MESSAGE_BOX}, - NULL AS ${SmsDatabase.STATUS}, - ${MmsDatabase.MESSAGE_SIZE}, - ${MmsDatabase.EXPIRY}, - ${MmsDatabase.STATUS}, - ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, - ${MmsSmsColumns.READ_RECEIPT_COUNT}, - ${MmsSmsColumns.EXPIRES_IN}, - ${MmsSmsColumns.EXPIRE_STARTED}, - ${MmsSmsColumns.NOTIFIED}, - '${MmsSmsDatabase.MMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, - ${MmsDatabase.QUOTE_ID}, - ${MmsDatabase.QUOTE_AUTHOR}, - ${MmsDatabase.QUOTE_BODY}, - ${MmsDatabase.QUOTE_MISSING}, - ${MmsDatabase.QUOTE_ATTACHMENT}, - ${MmsDatabase.LINK_PREVIEWS}, - ${MmsSmsColumns.HAS_MENTION}, - ($mmsHashQuery) AS ${MmsSmsColumns.SERVER_HASH}, - ${MmsSmsColumns.PRO_MESSAGE_FEATURES}, - ${MmsSmsColumns.PRO_PROFILE_FEATURES} - FROM ${MmsDatabase.TABLE_NAME} - $whereStatement - """ - - val orderStatement = order?.let { "ORDER BY $it" }.orEmpty() - val limitStatement = limit?.let { "LIMIT $it" }.orEmpty() - - return """ - WITH combined AS ( - $smsQuery - UNION ALL - $mmsQuery - ) - - SELECT $projection - FROM combined - $orderStatement - $limitStatement - """ -} - -/** - * Build a query to get the maximum timestamp (date sent) in a thread up to and including - * the timestamp of the given message ID. - * - * This query will also look at reactions associated with messages in the thread - * to ensure that if there are reactions with later timestamps, they are considered - * as well. - * - * @return A pair containing the SQL query string and an array of parameters to bind. - * The query will return at most one row of "maxTimestamp", "threadId". - */ -fun buildMaxTimestampInThreadUpToQuery(id: MessageId): Pair> { - val msgTable = if (id.mms) MmsDatabase.TABLE_NAME else SmsDatabase.TABLE_NAME - val dateSentColumn = if (id.mms) MmsDatabase.DATE_SENT else SmsDatabase.DATE_SENT - val threadIdColumn = if (id.mms) MmsSmsColumns.THREAD_ID else SmsDatabase.THREAD_ID - - // The query below does this: - // 1. Query the given message, find out its thread id and its date sent - // 2. Find all the messages in this thread before this messages (using result from step 1) - // 3. With this message + earlier messages, grab all the reactions associated with them - // 4. Look at the max date among the reactions returned from step 3 - // 5. Return the max between this message's date and the max reaction date - return """ - SELECT - MAX( - mainMessage.$dateSentColumn, - IFNULL( - ( - SELECT MAX(r.${ReactionDatabase.DATE_SENT}) - FROM ${ReactionDatabase.TABLE_NAME} r - INDEXED BY reaction_message_id_is_mms_index - WHERE (r.${ReactionDatabase.MESSAGE_ID}, r.${ReactionDatabase.IS_MMS}) IN ( - SELECT s.${MmsSmsColumns.ID}, FALSE - FROM ${SmsDatabase.TABLE_NAME} s - WHERE s.${SmsDatabase.THREAD_ID} = mainMessage.${threadIdColumn} AND - s.${SmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn - - UNION ALL - - SELECT m.${MmsSmsColumns.ID}, TRUE - FROM ${MmsDatabase.TABLE_NAME} m - WHERE m.${MmsSmsColumns.THREAD_ID} = mainMessage.${threadIdColumn} AND - m.${MmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn - ) - ), - 0 - ) - ) AS maxTimestamp, - mainMessage.$threadIdColumn AS threadId - FROM $msgTable mainMessage - WHERE mainMessage.${MmsSmsColumns.ID} = ? - """ to arrayOf(id.id) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index f009075a38..d3da7d8035 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -710,16 +710,8 @@ class RecipientRepository @Inject constructor( } RecipientData.Contact( - name = contact.name, - nickname = contact.nickname.takeIf { it.isNotBlank() }, - avatar = contact.profilePicture.toRemoteFile(), - approved = contact.approved, - approvedMe = contact.approvedMe, - blocked = contact.blocked, - expiryMode = contact.expiryMode, - priority = contact.priority, + configData = contact, proData = null, // final ProData will be calculated later - profileUpdatedAt = contact.profileUpdatedEpochSeconds.secondsToInstant(), ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 9ec1ee556c..6077aa2d15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -24,6 +24,7 @@ import android.database.Cursor; import androidx.collection.ArraySet; +import androidx.collection.MutableLongObjectMap; import androidx.sqlite.db.SupportSQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase; @@ -36,20 +37,19 @@ import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.network.SnodeClock; import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.preferences.CommunicationPreferences; +import org.thoughtcrime.securesms.preferences.PreferenceStorage; import org.thoughtcrime.securesms.pro.ProFeatureExtKt; import java.io.Closeable; import java.util.ArrayList; import java.util.Collection; -import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -59,6 +59,12 @@ import dagger.Lazy; import dagger.hilt.android.qualifiers.ApplicationContext; +import kotlin.Unit; +import kotlin.collections.ArraysKt; +import kotlinx.coroutines.channels.BufferOverflow; +import kotlinx.coroutines.flow.MutableSharedFlow; +import kotlinx.coroutines.flow.SharedFlow; +import kotlinx.coroutines.flow.SharedFlowKt; import network.loki.messenger.libsession_util.protocol.ProFeature; /** @@ -135,26 +141,48 @@ public static void addProFeatureColumns(SupportSQLiteDatabase db) { db.execSQL("ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + PRO_PROFILE_FEATURES + " INTEGER NOT NULL DEFAULT 0"); } + public static void addOutgoingColumn(SupportSQLiteDatabase db) { + final String allOutgoingMessageTypeSet = ArraysKt.joinToString( + Types.OUTGOING_MESSAGE_TYPES, + /* separator */ ",", + /* prefix */ "(", + /* postfix */ ")", + /* limit */ -1, + /* truncated */ "", + /* transform */ (value) -> Long.toString(value) + ); + + db.execSQL("ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + IS_OUTGOING + + " BOOLEAN GENERATED ALWAYS AS ((" + TYPE + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK +") IN " + allOutgoingMessageTypeSet + ") VIRTUAL"); + } + private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache(); private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); private final RecipientRepository recipientRepository; private final SnodeClock snodeClock; - private final Lazy<@NonNull ThreadDatabase> threadDatabase; private final Lazy<@NonNull ReactionDatabase> reactionDatabase; + final Provider<@NonNull PreferenceStorage> prefs; + + final MutableSharedFlow changeNotification + = SharedFlowKt.MutableSharedFlow(0, 24, BufferOverflow.DROP_OLDEST); @Inject public SmsDatabase(@ApplicationContext Context context, Provider databaseHelper, RecipientRepository recipientRepository, SnodeClock snodeClock, - Lazy<@NonNull ThreadDatabase> threadDatabase, - Lazy<@NonNull ReactionDatabase> reactionDatabase) { + Lazy<@NonNull ReactionDatabase> reactionDatabase, + Provider<@NonNull PreferenceStorage> prefs) { super(context, databaseHelper); this.recipientRepository = recipientRepository; this.snodeClock = snodeClock; - this.threadDatabase = threadDatabase; this.reactionDatabase = reactionDatabase; + this.prefs = prefs; + } + + public SharedFlow getChangeNotification() { + return changeNotification; } protected String getTableName() { @@ -165,25 +193,18 @@ private void updateTypeBitmask(long id, long maskOff, long maskOn) { Log.i("MessageDatabase", "Updating ID: " + id + " to base type: " + maskOn); SQLiteDatabase db = getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + + try (final Cursor cursor = db.rawQuery("UPDATE " + TABLE_NAME + " SET " + TYPE + " = (" + TYPE + " & " + (Types.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + - " WHERE " + ID + " = ?", new String[] {id+""}); - - long threadId = getThreadIdForMessage(id); - - threadDatabase.get().update(threadId, false); - } - - public long getThreadIdForMessage(long id) { - String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?"; - String[] sqlArgs = new String[] {id+""}; - SQLiteDatabase db = getReadableDatabase(); - - try (Cursor cursor = db.rawQuery(sql, sqlArgs)) { - if (cursor != null && cursor.moveToFirst()) - return cursor.getLong(0); - else - return -1; + " WHERE " + ID + " = ?" + + " RETURNING " + THREAD_ID, id)) { + if (cursor.moveToNext()) { + long threadId = cursor.getLong(0); + changeNotification.tryEmit(new MessageChanges( + MessageChanges.ChangeType.Updated, + new MessageId(id, false), + threadId + )); + } } } @@ -235,6 +256,7 @@ public void markAsDeleted(long messageId, boolean isOutgoing, String displayedMe database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); + // We are relying on updateTypeBitmask to push change notification updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, isOutgoing? MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE : MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE ); @@ -242,15 +264,16 @@ public void markAsDeleted(long messageId, boolean isOutgoing, String displayedMe @Override public void markExpireStarted(long id, long startedAtTimestamp) { - ContentValues contentValues = new ContentValues(); - contentValues.put(EXPIRE_STARTED, startedAtTimestamp); - SQLiteDatabase db = getWritableDatabase(); try (final Cursor cursor = db.rawQuery("UPDATE " + TABLE_NAME + " SET " + EXPIRE_STARTED + " = ? " + "WHERE " + ID + " = ? RETURNING " + THREAD_ID, startedAtTimestamp, id)) { if (cursor.moveToNext()) { long threadId = cursor.getLong(0); - threadDatabase.get().update(threadId, false); + changeNotification.tryEmit(new MessageChanges( + MessageChanges.ChangeType.Updated, + new MessageId(id, false), + threadId + )); } } } @@ -259,17 +282,8 @@ public void markAsSentFailed(long id) { updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_FAILED_TYPE); } - public void markAsNotified(long id) { - SQLiteDatabase database = getWritableDatabase(); - ContentValues contentValues = new ContentValues(); - - contentValues.put(NOTIFIED, 1); - - database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); - } - public boolean isOutgoingMessage(long id) { - SQLiteDatabase database = getWritableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = null; boolean isOutgoing = false; @@ -302,17 +316,7 @@ private int getOutgoingProFeatureCountInternal(@NonNull String columnName, long SQLiteDatabase db = getReadableDatabase(); // outgoing clause - StringBuilder outgoingTypes = new StringBuilder(); - long[] types = MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES; - for (int i = 0; i < types.length; i++) { - if (i > 0) outgoingTypes.append(","); - outgoingTypes.append(types[i]); - } - - String outgoingSelection = - "(" + TYPE + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + outgoingTypes + ")"; - - String where = "(" + columnName + " & " + featureMask + ") != 0 AND " + outgoingSelection; + String where = "(" + columnName + " & " + featureMask + ") != 0 AND " + IS_OUTGOING; try (Cursor cursor = db.query(TABLE_NAME, new String[]{"COUNT(*)"}, where, null, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { @@ -324,7 +328,7 @@ private int getOutgoingProFeatureCountInternal(@NonNull String columnName, long } public boolean isDeletedMessage(long id) { - SQLiteDatabase database = getWritableDatabase(); + SQLiteDatabase database = getReadableDatabase(); Cursor cursor = null; boolean isDeleted = false; @@ -368,13 +372,17 @@ public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryRecei if (ourAddress.equals(theirAddress)) { long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); - + long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); database.execSQL("UPDATE " + TABLE_NAME + " SET " + columnName + " = " + columnName + " + 1 WHERE " + ID + " = ?", - new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); + new String[] {String.valueOf(id)}); - threadDatabase.get().update(threadId, false); + changeNotification.tryEmit(new MessageChanges( + MessageChanges.ChangeType.Updated, + new MessageId(id, false), + threadId + )); foundMessage = true; } } @@ -391,56 +399,24 @@ public void incrementReceiptCount(SyncMessageId messageId, boolean deliveryRecei } } - public List setMessagesRead(long threadId, long beforeTime) { - return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""}); - } - public List setMessagesRead(long threadId) { - return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0)", new String[] {String.valueOf(threadId)}); - } - - private List setMessagesRead(String where, String[] arguments) { - SQLiteDatabase database = getWritableDatabase(); - List results = new LinkedList<>(); - database.beginTransaction(); - try (final Cursor cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - long timestamp = cursor.getLong(2); - SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), timestamp); - ExpirationInfo expirationInfo = new ExpirationInfo(new MessageId(cursor.getLong(0), false), timestamp, cursor.getLong(4), cursor.getLong(5)); - - results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); - } - - ContentValues contentValues = new ContentValues(); - contentValues.put(READ, 1); - contentValues.put(REACTIONS_UNREAD, 0); - - database.update(TABLE_NAME, contentValues, where, arguments); - database.setTransactionSuccessful(); - } finally { - database.endTransaction(); - } - - return results; - } - public void updateSentTimestamp(long messageId, long newTimestamp) { SQLiteDatabase db = getWritableDatabase(); - db.rawExecSQL("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + - "WHERE " + ID + " = ?", newTimestamp, messageId); + try (final Cursor cursor = db.rawQuery("UPDATE " + TABLE_NAME + " SET " + DATE_SENT + " = ? " + + "WHERE " + ID + " = ? RETURNING " + THREAD_ID, newTimestamp, messageId)) { + if (cursor.moveToNext()) { + long threadId = cursor.getLong(0); + changeNotification.tryEmit(new MessageChanges( + MessageChanges.ChangeType.Updated, + new MessageId(messageId, false), + threadId + )); + } + } } - protected @Nullable InsertResult insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { - Address recipient = message.getSender(); - Address groupRecipient = message.getGroup(); - + protected @Nullable InsertResult insertMessageInbox(IncomingTextMessage message, long threadId, long type, long serverTimestamp) { boolean unread = (message.isSecureMessage() || message.isGroupMessage() || message.isUnreadCallMessage()); - long threadId; - - if (groupRecipient == null) threadId = threadDatabase.get().getOrCreateThreadIdFor(recipient); - else threadId = threadDatabase.get().getOrCreateThreadIdFor(groupRecipient); - if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; } else if (message.isGroupMessage()) { @@ -481,9 +457,11 @@ public void updateSentTimestamp(long messageId, long newTimestamp) { SQLiteDatabase db = getWritableDatabase(); long messageId = db.insert(TABLE_NAME, null, values); - if (runThreadUpdate) { - threadDatabase.get().update(threadId, true); - } + changeNotification.tryEmit(new MessageChanges( + MessageChanges.ChangeType.Added, + new MessageId(messageId, false), + threadId + )); return new InsertResult(messageId, threadId); } @@ -504,23 +482,20 @@ private long getCallMessageTypeMask(CallMessageType callMessageType) { } } - public @Nullable InsertResult insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate); + public @Nullable InsertResult insertMessageInbox(IncomingTextMessage message, long threadId) { + return insertMessageInbox(message, threadId , Types.BASE_INBOX_TYPE, 0); } - public @Nullable InsertResult insertCallMessage(IncomingTextMessage message) { - return insertMessageInbox(message, 0, 0, true); + public @Nullable InsertResult insertCallMessage(IncomingTextMessage message, long threadId) { + return insertMessageInbox(message, threadId, 0, 0); } - public @Nullable InsertResult insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate); + public @Nullable InsertResult insertMessageInbox(IncomingTextMessage message, long threadId, long serverTimestamp) { + return insertMessageInbox(message, threadId, Types.BASE_INBOX_TYPE, serverTimestamp); } - public @Nullable InsertResult insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { - if (threadId == -1) { - threadId = threadDatabase.get().getOrCreateThreadIdFor(message.getRecipient()); - } - long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp, runThreadUpdate); + public @Nullable InsertResult insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp) { + long messageId = insertMessageOutbox(threadId, message, false, serverTimestamp); if (messageId == -1) { return null; } @@ -529,8 +504,7 @@ private long getCallMessageTypeMask(CallMessageType callMessageType) { } public long insertMessageOutbox(long threadId, OutgoingTextMessage message, - boolean forceSms, long date, - boolean runThreadUpdate) + boolean forceSms, long date) { long type = Types.BASE_SENDING_TYPE | Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT; @@ -561,20 +535,15 @@ public long insertMessageOutbox(long threadId, OutgoingTextMessage message, return -1; } - SQLiteDatabase db = getWritableDatabase(); - long messageId = db.insert(TABLE_NAME, ADDRESS, contentValues); + final long id = getWritableDatabase().insert(TABLE_NAME, ADDRESS, contentValues); - if (runThreadUpdate) { - threadDatabase.get().update(threadId, true); - } - long lastSeen = threadDatabase.get().getLastSeenAndHasSent(threadId).first(); - if (lastSeen < message.getSentTimestampMillis()) { - threadDatabase.get().setLastSeen(threadId, message.getSentTimestampMillis()); - } - - threadDatabase.get().setHasSent(threadId, true); + changeNotification.tryEmit(new MessageChanges( + MessageChanges.ChangeType.Added, + new MessageId(id, false), + threadId + )); - return messageId; + return id; } @Override public List getExpiredMessageIDs(long nowMills) { @@ -610,26 +579,23 @@ public long getNextExpiringTimestamp() { @Override public void deleteMessage(long messageId) { - doDeleteMessages(true, ID + " = ?", messageId); + doDeleteMessages(ID + " = ?", messageId); } @Override - public void deleteMessages(Collection messageIds) {doDeleteMessages(true, - ID + " IN (SELECT value FROM json_each(?))", + public void deleteMessages(Collection messageIds) { + doDeleteMessages( + ID + " IN (SELECT value FROM json_each(?))", new JSONArray(messageIds).toString() ); } @Override public void updateThreadId(long fromId, long toId) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(MmsSmsColumns.THREAD_ID, toId); - - SQLiteDatabase db = getWritableDatabase(); - db.update(TABLE_NAME, contentValues, THREAD_ID + " = ?", new String[] {fromId + ""}); + SmsDatabaseExtKt.updateThreadId(this, fromId, toId); } - private boolean isDuplicate(IncomingTextMessage message, long threadId) { + private boolean isDuplicate(IncomingTextMessage message, long threadId) { SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.query(TABLE_NAME, null, DATE_SENT + " = ? AND " + ADDRESS + " = ? AND " + THREAD_ID + " = ?", new String[]{String.valueOf(message.getSentTimestampMillis()), message.getSender().toString(), String.valueOf(threadId)}, @@ -655,44 +621,45 @@ private boolean isDuplicate(OutgoingTextMessage message, long threadId) { } } - private boolean doDeleteMessages(final boolean updateThread, @NonNull final String where, @Nullable final Object...args) { - final String sql = "DELETE FROM " + TABLE_NAME + " WHERE " + where + " RETURNING " + THREAD_ID; - final HashSet deletedMessageThreadIds = new HashSet<>(); + private void doDeleteMessages(@NonNull final String where, @Nullable final Object...args) { + final String sql = "DELETE FROM " + TABLE_NAME + " WHERE " + where + " RETURNING " + ID + "," + THREAD_ID; + final MutableLongObjectMap> deletedByThreadIDs = new MutableLongObjectMap<>(); try (final Cursor cursor = getWritableDatabase().rawQuery(sql, args)) { - while (cursor.moveToNext()) { - final long threadId = cursor.getLong(0); - deletedMessageThreadIds.add(threadId); - } - } - - if (updateThread) { - for (final long threadId : deletedMessageThreadIds) { - threadDatabase.get().update(threadId, false); + while (cursor.moveToNext()) { + deletedByThreadIDs.getOrPut(cursor.getLong(1), ArrayList::new) + .add(new MessageId(cursor.getLong(0), false)); } } - return !deletedMessageThreadIds.isEmpty(); + deletedByThreadIDs.forEach((threadId, deleted) -> { + changeNotification.tryEmit(new MessageChanges( + MessageChanges.ChangeType.Deleted, + deleted, + threadId + )); + + return Unit.INSTANCE; + }); } void deleteMessagesFrom(long threadId, String fromUser) { doDeleteMessages( - true, - THREAD_ID + " = ? AND " + ADDRESS + " = ?", + THREAD_ID + " = ? AND " + ADDRESS + " = ?", threadId, fromUser ); } void deleteMessagesInThreadBeforeDate(long threadId, long date) { - doDeleteMessages(true, THREAD_ID + " = ? AND " + DATE_SENT + " < ?", threadId, date); + doDeleteMessages(THREAD_ID + " = ? AND " + DATE_SENT + " < ?", threadId, date); } void deleteThread(long threadId) { - doDeleteMessages(true, THREAD_ID + " = ?", threadId); + doDeleteMessages(THREAD_ID + " = ?", threadId); } - public void deleteThreads(@NonNull Collection threadIds, boolean updateThreads) { - doDeleteMessages(updateThreads, THREAD_ID + " IN (SELECT value FROM json_each(?))", + public void deleteThreads(@NonNull Collection threadIds) { + doDeleteMessages(THREAD_ID + " IN (SELECT value FROM json_each(?))", new JSONArray(threadIds).toString()); } @@ -747,10 +714,12 @@ public SmsMessageRecord getCurrent() { ProFeatureExtKt.toProMessageFeatures(cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.PRO_MESSAGE_FEATURES)), proFeatures); ProFeatureExtKt.toProProfileFeatures(cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.PRO_PROFILE_FEATURES)), proFeatures); - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + if (!prefs.get().get(CommunicationPreferences.INSTANCE.getREAD_RECEIPT_ENABLED())) { readReceiptCount = 0; } + String serverHash = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsColumns.SERVER_HASH)); + Recipient recipient = recipientRepository.getRecipientSync(address); List reactions = reactionDatabase.get().getReactions(cursor); @@ -770,7 +739,8 @@ public SmsMessageRecord getCurrent() { readReceiptCount, reactions, hasMention, - proFeatures); + proFeatures, + serverHash); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabaseExt.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabaseExt.kt new file mode 100644 index 0000000000..91dc14c873 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabaseExt.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.util.asSequence + +fun SmsDatabase.updateThreadId(fromId: Long, toId: Long) { + if (fromId == toId) return + + //language=roomsql + val updatedMessageIds = writableDatabase.query(""" + UPDATE ${SmsDatabase.TABLE_NAME} + SET ${SmsDatabase.THREAD_ID} = ? + WHERE ${SmsDatabase.THREAD_ID} = ? + RETURNING ${SmsDatabase.ID} + """, arrayOf(toId, fromId)).use { cursor -> + cursor.asSequence().map { MessageId(it.getLong(0), false) }.toList() + } + + if (updatedMessageIds.isNotEmpty()) { + changeNotification.tryEmit(MessageChanges( + changeType = MessageChanges.ChangeType.Deleted, + ids = updatedMessageIds, + threadId = fromId + )) + + changeNotification.tryEmit(MessageChanges( + changeType = MessageChanges.ChangeType.Added, + ids = updatedMessageIds, + threadId = toId + )) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index ac573afe31..5afb18c5b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -51,7 +51,6 @@ import org.session.libsession.utilities.GroupDisplayInfo import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.getGroup -import org.session.libsession.utilities.isCommunity import org.session.libsession.utilities.isCommunityInbox import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData @@ -75,9 +74,8 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol +import org.thoughtcrime.securesms.util.getOrConstructConvo import org.thoughtcrime.securesms.util.findCause -import java.time.Instant -import java.time.ZoneId import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -129,8 +127,8 @@ open class Storage @Inject constructor( return attachmentDatabase.getAttachmentsForMessage(mmsMessageId) } - override fun getLastSeen(threadId: Long): Long { - return threadDatabase.getLastSeenAndHasSent(threadId)?.first() ?: 0L + override fun getLastSeen(threadAddress: Address.Conversable): Long? { + return threadDatabase.getLastSeen(threadAddress)?.toEpochMilliseconds() } override fun ensureMessageHashesAreSender( @@ -179,49 +177,60 @@ open class Storage @Inject constructor( return messages.map { it.second } // return the message hashes } - override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean, updateNotification: Boolean) { - val threadDb = threadDatabase - val threadAddress = threadDb.getRecipientForThreadId(threadId) ?: return - // don't set the last read in the volatile if we didn't set it in the DB - if (!threadDb.markAllAsRead(threadId, lastSeenTime, force, updateNotification) && !force) return - - // don't process configs for inbox recipients - if (threadAddress.isCommunityInbox) return - - val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() + override fun updateConversationLastSeenIfNeeded( + threadAddress: Address.Conversable, + lastSeenTime: Long + ) { + var shouldUpdateLastRead = false configFactory.withMutableUserConfigs { configs -> - val config = configs.convoInfoVolatile - val convo = getConvo( - threadAddress = threadAddress, - config = config, - groupConfig = configs.userGroups - ) ?: return@withMutableUserConfigs - convo.lastRead = lastSeenTime + val convo = configs.getOrConstructConvo(threadAddress) + val currentLastRead = convo.lastRead - if(convo.unread){ + if (convo.unread) { convo.unread = lastSeenTime < currentLastRead } - config.set(convo) + shouldUpdateLastRead = lastSeenTime > currentLastRead + if (shouldUpdateLastRead) { + convo.lastRead = lastSeenTime + } + + configs.convoInfoVolatile.set(convo) + } + + // Normally, the config will be synced to db automatically. + // But there are cases the config reject our request, mainly due to lastSeenTime + // being too ancient. + // So we manually update the db here also to protect against this case + if (shouldUpdateLastRead) { + threadDatabase.upsertThreadLastSeen( + listOf(threadAddress to kotlin.time.Instant.fromEpochMilliseconds(lastSeenTime)) + ) } } + override fun updateConversationLastSeenIfNeeded( + threadId: Long, + lastSeenTime: Long + ) { + val threadAddress = threadDatabase.getRecipientForThreadId(threadId) as? Address.Conversable ?: return + updateConversationLastSeenIfNeeded( + threadAddress = threadAddress, + lastSeenTime = lastSeenTime + ) + } + override fun markConversationAsReadUpToMessage(messageId: MessageId) { val maxTimestampMillsAndThreadId = mmsSmsDatabase.getMaxTimestampInThreadUpTo(messageId) if (maxTimestampMillsAndThreadId != null) { val threadId = maxTimestampMillsAndThreadId.second val maxTimestamp = maxTimestampMillsAndThreadId.first - if (getLastSeen(threadId) < maxTimestamp) { - Log.d(TAG, "Marking last seen for thread $threadId as ${Instant.ofEpochMilli(maxTimestamp).atZone( - ZoneId.systemDefault())}") - markConversationAsRead( - threadId = threadId, - lastSeenTime = maxTimestamp, - force = false, - updateNotification = true - ) - } + val threadAddress = threadDatabase.getRecipientForThreadId(threadId) as? Address.Conversable ?: return + updateConversationLastSeenIfNeeded( + threadAddress = threadAddress, + lastSeenTime = maxTimestamp + ) } } @@ -276,11 +285,6 @@ open class Storage @Inject constructor( } } - override fun updateThread(threadId: Long, unarchive: Boolean) { - val threadDb = threadDatabase - threadDb.update(threadId, unarchive) - } - override fun persist( threadRecipient: Recipient, message: VisibleMessage, @@ -306,8 +310,7 @@ open class Storage @Inject constructor( message.syncTarget!!.toAddress() } else (threadRecipient.address as? Address.Group) ?: senderAddress - if (message.threadID == null && !targetAddress.isCommunity) { - // open group recipients should explicitly create threads + if (message.threadID == null) { message.threadID = getOrCreateThreadIdFor(targetAddress) } val expiryMode = message.expiryMode @@ -342,7 +345,11 @@ open class Storage @Inject constructor( expiresInMillis = expiresInMillis, expireStartedAt = expireStartedAt ) - mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) + mmsDatabase.insertSecureDecryptedMessageOutbox( + mediaMessage, + message.threadID!!, + message.sentTimestamp!! + ) } else { // It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment val signalServiceAttachments = attachments.mapNotNull { @@ -358,7 +365,11 @@ open class Storage @Inject constructor( quote = quotes, linkPreviews = linkPreviews ) - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) + mmsDatabase.insertSecureDecryptedMessageInbox( + mediaMessage, + message.threadID!!, + message.receivedTimestamp ?: 0 + ) } messageID = insertResult?.messageId?.let { MessageId(it, mms = true) } @@ -383,7 +394,11 @@ open class Storage @Inject constructor( expireStartedAtMillis = expireStartedAt ) - smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) + smsDatabase.insertMessageOutbox( + message.threadID!!, + textMessage, + message.sentTimestamp!! + ) } else { val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation( json = json, @@ -400,7 +415,11 @@ open class Storage @Inject constructor( expiresInMillis = expiresInMillis, expireStartedAt = expireStartedAt ) - smsDatabase.insertMessageInbox(textMessage.copy(isSecureMessage = true), message.receivedTimestamp ?: 0, runThreadUpdate) + smsDatabase.insertMessageInbox( + textMessage.copy(isSecureMessage = true), + message.threadID!!, + message.receivedTimestamp ?: 0 + ) } messageID = insertResult?.messageId?.let { MessageId(it, mms = false) } } @@ -822,8 +841,7 @@ open class Storage @Inject constructor( val infoMessageID = mmsDB.insertMessageOutbox( infoMessage, threadID, - false, - runThreadUpdate = true + false ) mmsDB.markAsSent(infoMessageID, true) return MessageId(infoMessageID, mms = true) @@ -845,10 +863,13 @@ open class Storage @Inject constructor( isGroupUpdateMessage = true, ) val smsDB = smsDatabase - val insertResult = smsDB.insertMessageInbox(m.copy( - isGroupUpdateMessage = true, - message = inviteJson - ), true) + val insertResult = smsDB.insertMessageInbox( + m.copy( + isGroupUpdateMessage = true, + message = inviteJson + ), + threadID + ) return insertResult?.messageId?.let { MessageId(it, mms = false) } } } @@ -895,11 +916,6 @@ open class Storage @Inject constructor( } } - override fun getLastUpdated(threadID: Long): Long { - val threadDB = threadDatabase - return threadDB.getLastUpdated(threadID) - } - override fun trimThreadBefore(threadID: Long, timestamp: Long) { val threadDB = threadDatabase threadDB.trimThreadBefore(threadID, timestamp) @@ -1011,11 +1027,6 @@ open class Storage @Inject constructor( } } - override fun isRead(threadId: Long) : Boolean { - val threadDB = threadDatabase - return threadDB.isRead(threadId) - } - override fun setThreadCreationDate(threadId: Long, newDate: Long) { val threadDb = threadDatabase threadDb.setCreationDate(threadId, newDate) @@ -1039,8 +1050,6 @@ open class Storage @Inject constructor( mmsDatabase.deleteMessagesFrom(threadID, fromUser.toString()) } - threadDb.setRead(threadID, true) - return true } @@ -1082,7 +1091,7 @@ open class Storage @Inject constructor( message ) - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId) } /** @@ -1107,7 +1116,7 @@ open class Storage @Inject constructor( linkPreviews = emptyList(), dataExtractionNotification = null ) - mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true) + mmsDatabase.insertSecureDecryptedMessageInbox(message, threadId) } override fun insertCallMessage( @@ -1126,16 +1135,8 @@ open class Storage @Inject constructor( expiresInMillis = expiresInMillis, expireStartedAt = expireStartedAt ) - smsDatabase.insertCallMessage(callMessage) - } - - override fun conversationHasOutgoing(userPublicKey: String): Boolean { - val database = threadDatabase - val threadId = database.getThreadIdIfExistsFor(userPublicKey) - - if (threadId == -1L) return false - return database.getLastSeenAndHasSent(threadId).second() ?: false + smsDatabase.insertCallMessage(callMessage, threadDatabase.getOrCreateThreadIdFor(address)) } override fun getLastInboxMessageId(server: String): Long? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 0dc4ca500d..0094878529 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -17,13 +17,11 @@ */ package org.thoughtcrime.securesms.database; -import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID; -import static org.thoughtcrime.securesms.database.GroupDatabase.TYPED_GROUP_PROJECTION; - import android.content.ContentValues; import android.content.Context; import android.database.Cursor; -import android.net.Uri; + +import androidx.collection.ArrayMap; import net.zetetic.database.sqlcipher.SQLiteDatabase; @@ -31,42 +29,20 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsession.network.SnodeClock; import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.AddressKt; -import org.session.libsession.utilities.ConfigFactoryProtocol; -import org.session.libsession.utilities.ConfigFactoryProtocolKt; import org.session.libsession.utilities.GroupUtil; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.AccountId; import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.Pair; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.database.model.GroupThreadStatus; -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.database.model.content.MessageContent; -import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent; -import org.thoughtcrime.securesms.mms.Slide; -import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.notifications.MarkReadProcessor; -import org.thoughtcrime.securesms.util.SharedConfigUtilsKt; -import java.io.Closeable; -import java.time.ZonedDateTime; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Provider; @@ -80,10 +56,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow; import kotlinx.coroutines.flow.SharedFlowKt; import kotlinx.serialization.json.Json; -import network.loki.messenger.libsession_util.util.GroupInfo; @Singleton -public class ThreadDatabase extends Database implements OnAppStartupComponent { +public class ThreadDatabase extends Database { private static final String TAG = ThreadDatabase.class.getSimpleName(); @@ -147,24 +122,6 @@ public class ThreadDatabase extends Database implements OnAppStartupComponent { "CREATE UNIQUE INDEX thread_addresses ON " + TABLE_NAME + " (" + ADDRESS + ");" }; - private static final String[] THREAD_PROJECTION = { - ID, THREAD_CREATION_DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, DISTRIBUTION_TYPE, ERROR, SNIPPET_TYPE, - SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED, SNIPPET_CONTENT, - }; - - private static final List TYPED_THREAD_PROJECTION = - Arrays.stream(THREAD_PROJECTION) - .map(columnName -> TABLE_NAME + "." + columnName) - .collect(Collectors.toList()); - - private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = - CollectionsKt.plus( - CollectionsKt.plus( - TYPED_THREAD_PROJECTION, - TYPED_GROUP_PROJECTION - ), LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId); - - public static String getCreatePinnedCommand() { return "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + IS_PINNED + " INTEGER DEFAULT 0;"; @@ -229,52 +186,29 @@ public static void migrateLegacyCommunityAddresses(final SQLiteDatabase db) { } - private final MutableSharedFlow updateNotifications = SharedFlowKt.MutableSharedFlow(0, 256, BufferOverflow.DROP_OLDEST); - private final Json json; - private final TextSecurePreferences prefs; - private final SnodeClock snodeClock; + private final MutableSharedFlow updateNotifications + = SharedFlowKt.MutableSharedFlow(0, 256, BufferOverflow.DROP_OLDEST); - private final Lazy<@NonNull RecipientRepository> recipientRepository; - private final Lazy<@NonNull MmsSmsDatabase> mmsSmsDatabase; - private final Lazy<@NonNull ConfigFactoryProtocol> configFactory; - private final Lazy<@NonNull MessageNotifier> messageNotifier; + final Lazy<@NonNull RecipientRepository> recipientRepository; + final Lazy<@NonNull MmsSmsDatabase> mmsSmsDatabase; private final Lazy<@NonNull MmsDatabase> mmsDatabase; private final Lazy<@NonNull SmsDatabase> smsDatabase; - private final Lazy<@NonNull MarkReadProcessor> markReadProcessor; + @NonNull final Json json; @Inject public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context context, Provider databaseHelper, Lazy<@NonNull RecipientRepository> recipientRepository, Lazy<@NonNull MmsSmsDatabase> mmsSmsDatabase, - Lazy<@NonNull ConfigFactoryProtocol> configFactory, - Lazy<@NonNull MessageNotifier> messageNotifier, Lazy<@NonNull MmsDatabase> mmsDatabase, Lazy<@NonNull SmsDatabase> smsDatabase, - Lazy<@NonNull MarkReadProcessor> markReadProcessor, - TextSecurePreferences prefs, - SnodeClock snodeClock, - Json json) { + @NonNull Json json) { super(context, databaseHelper); this.recipientRepository = recipientRepository; this.mmsSmsDatabase = mmsSmsDatabase; - this.configFactory = configFactory; - this.messageNotifier = messageNotifier; this.mmsDatabase = mmsDatabase; this.smsDatabase = smsDatabase; - this.markReadProcessor = markReadProcessor; - this.snodeClock = snodeClock; - this.json = json; - this.prefs = prefs; - } - - @Override - public void onPostAppStarted() { - if (!prefs.getMigratedDisappearingMessagesToMessageContent()) { - migrateDisappearingMessagesToMessageContent(); - prefs.setMigratedDisappearingMessagesToMessageContent(true); - } } @NonNull @@ -282,54 +216,6 @@ public Flow getUpdateNotifications() { return updateNotifications; } - // As we migrate disappearing messages to MessageContent, we need to ensure that - // if they appear in the snippet, they have to be re-generated with the new MessageContent. - private void migrateDisappearingMessagesToMessageContent() { - String sql = "SELECT " + ID + " FROM " + TABLE_NAME + - " WHERE " + SNIPPET_TYPE + " & " + MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT + " != 0"; - try (final Cursor cursor = getReadableDatabase().rawQuery(sql)) { - while (cursor.moveToNext()) { - update(cursor.getLong(0), false); - } - } - } - - private void updateThread(long threadId, long count, String body, @Nullable Uri attachment, @Nullable MessageContent messageContent, - long date, int status, int deliveryReceiptCount, long type, boolean unarchive, - long expiresIn, int readReceiptCount) - { - ContentValues contentValues = new ContentValues(7); - contentValues.put(THREAD_CREATION_DATE, date - date % 1000); - contentValues.put(MESSAGE_COUNT, count); - if (!body.isEmpty()) { - contentValues.put(SNIPPET, body); - } - contentValues.put(SNIPPET_CONTENT, messageContent == null ? null : json.encodeToString(MessageContent.Companion.serializer(), messageContent)); - contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); - contentValues.put(SNIPPET_TYPE, type); - contentValues.put(STATUS, status); - contentValues.put(DELIVERY_RECEIPT_COUNT, deliveryReceiptCount); - contentValues.put(READ_RECEIPT_COUNT, readReceiptCount); - contentValues.put(EXPIRES_IN, expiresIn); - - if (unarchive) { contentValues.put(ARCHIVED, 0); } - - SQLiteDatabase db = getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); - - updateUnreadCounts(threadId); - } - - public void clearSnippet(long threadId){ - ContentValues contentValues = new ContentValues(1); - - contentValues.put(SNIPPET, ""); - contentValues.put(SNIPPET_CONTENT, ""); - - SQLiteDatabase db = getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""}); - notifyThreadUpdated(threadId); - } public void deleteThread(long threadId) { SQLiteDatabase db = getWritableDatabase(); @@ -373,7 +259,7 @@ public EnsureThreadsResult ensureThreads(@NonNull final Iterable(cursor.getCount()); + deletedThreads = new ArrayMap<>(cursor.getCount()); while (cursor.moveToNext()) { deletedThreads.put( Address.fromSerialized(cursor.getString(1)), @@ -388,7 +274,7 @@ public EnsureThreadsResult ensureThreads(@NonNull final Iterable(cursor.getCount()); + createdThreads = new ArrayMap<>(cursor.getCount()); while (cursor.moveToNext()) { createdThreads.put( Address.fromSerialized(cursor.getString(1)), @@ -419,49 +305,7 @@ public void trimThreadBefore(long threadId, long timestamp) { Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); smsDatabase.get().deleteMessagesInThreadBeforeDate(threadId, timestamp); mmsDatabase.get().deleteMessagesInThreadBeforeDate(threadId, timestamp, false); - update(threadId, false); - notifyThreadUpdated(threadId); - } - - public List setRead(long threadId, long lastReadTime) { - - final List smsRecords = smsDatabase.get().setMessagesRead(threadId, lastReadTime); - final List mmsRecords = mmsDatabase.get().setMessagesRead(threadId, lastReadTime); - - ContentValues contentValues = new ContentValues(2); - contentValues.put(READ, smsRecords.isEmpty() && mmsRecords.isEmpty()); - contentValues.put(LAST_SEEN, lastReadTime); - - SQLiteDatabase db = getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); - - notifyThreadUpdated(threadId); - - return CollectionsKt.plus(smsRecords, mmsRecords); - } - - public List setRead(long threadId, boolean lastSeen) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(READ, 1); - contentValues.put(UNREAD_COUNT, 0); - contentValues.put(UNREAD_MENTION_COUNT, 0); - - if (lastSeen) { - contentValues.put(LAST_SEEN, snodeClock.currentTimeMillis()); - } - - SQLiteDatabase db = getWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); - - final List smsRecords = smsDatabase.get().setMessagesRead(threadId); - final List mmsRecords = mmsDatabase.get().setMessagesRead(threadId); - notifyThreadUpdated(threadId); - - return new LinkedList() {{ - addAll(smsRecords); - addAll(mmsRecords); - }}; } public void setCreationDate(long threadId, long date) { @@ -472,146 +316,12 @@ public void setCreationDate(long threadId, long date) { if (updated > 0) notifyThreadUpdated(threadId); } - public void setCreationDates(@NonNull final Map dates) { - if (dates.isEmpty()) return; - - final SQLiteDatabase db = getWritableDatabase(); - db.beginTransaction(); - - ContentValues contentValues = new ContentValues(1); - - try { - for (Map.Entry entry : dates.entrySet()) { - contentValues.put(THREAD_CREATION_DATE, entry.getValue().toInstant().toEpochMilli()); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(entry.getKey())}); - } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - for (Long threadId : dates.keySet()) { - notifyThreadUpdated(threadId); - } - } - - @NonNull - public List getThreads(@Nullable Collection addresses) { + @NonNull + public List getThreads(@Nullable Collection addresses) { if (addresses == null || addresses.isEmpty()) return Collections.emptyList(); - final String query = createQuery( - TABLE_NAME + "." + ADDRESS + " IN (SELECT value FROM json_each(?))" - ); - - final String selectionArg = new JSONArray(CollectionsKt.map(addresses, Address::getAddress)).toString(); - - try (final Cursor cursor = getReadableDatabase().rawQuery(query, selectionArg)) { - final ArrayList threads = new ArrayList<>(cursor.getCount()); - final Reader reader = new Reader(cursor); - ThreadRecord thread; - while ((thread = reader.getNext()) != null) { - threads.add(thread); - } - - return threads; - } - } - - /** - * @return All threads in the database, with their thread ID and Address. Note that - * threads don't necessarily mean conversations, as whether you have a conversation - * or not depend on the config data. This method returns all threads that exist - * in the database, normally this is useful only for data integrity purposes. - */ - public List> getAllThreads() { - return getAllThreads(getReadableDatabase()); - } - - private List> getAllThreads(SQLiteDatabase db) { - final String query = "SELECT " + ID + ", " + ADDRESS + " FROM " + TABLE_NAME + " WHERE nullif(" + ADDRESS + ", '') IS NOT NULL"; - try (Cursor cursor = db.rawQuery(query, null)) { - List> threads = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); - String address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)); - if (address != null && !address.isEmpty()) { - threads.add(new kotlin.Pair<>(Address.fromSerialized(address), threadId)); - } - } - return threads; - } - } - - /** - * @param threadId - * @param timestamp - * @return true if we have set the last seen for the thread, false if there were no messages in the thread - */ - public boolean setLastSeen(long threadId, long timestamp) { - // edge case where we set the last seen time for a conversation before it loads messages (joining community for example) - Address forThreadId = getRecipientForThreadId(threadId); - if (mmsSmsDatabase.get().getConversationCount(threadId) <= 0 && forThreadId != null && AddressKt.isCommunity(forThreadId)) return false; - - SQLiteDatabase db = getWritableDatabase(); - - ContentValues contentValues = new ContentValues(1); - long lastSeenTime = timestamp == -1 ? snodeClock.currentTimeMillis() : timestamp; - contentValues.put(LAST_SEEN, lastSeenTime); - db.beginTransaction(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); - updateUnreadCounts(threadId); - db.setTransactionSuccessful(); - db.endTransaction(); - notifyThreadUpdated(threadId); - return true; - } - - private void updateUnreadCounts(long threadId) { - String smsCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0"; - String smsMentionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0 AND s."+SmsDatabase.HAS_MENTION+" = 1"; - String smsReactionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.REACTIONS_UNREAD+" = 1"; - String mmsCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0"; - String mmsMentionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0 AND m."+MmsDatabase.HAS_MENTION+" = 1"; - String mmsReactionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.REACTIONS_UNREAD+" = 1"; - String allSmsUnread = "(("+smsCountSubQuery+") + ("+smsReactionCountSubQuery+"))"; - String allMmsUnread = "(("+mmsCountSubQuery+") + ("+mmsReactionCountSubQuery+"))"; - String allUnread = "(("+allSmsUnread+") + ("+allMmsUnread+"))"; - String allUnreadMention = "(("+smsMentionCountSubQuery+") + ("+mmsMentionCountSubQuery+"))"; - - String reflectUpdates = "UPDATE "+TABLE_NAME+" AS t SET "+UNREAD_COUNT+" = "+allUnread+", "+UNREAD_MENTION_COUNT+" = "+allUnreadMention+" WHERE "+ID+" = ?"; - getWritableDatabase().rawExecSQL(reflectUpdates, threadId); - } - - - public Pair getLastSeenAndHasSent(long threadId) { - SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query(TABLE_NAME, new String[]{LAST_SEEN, HAS_SENT}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); - - try { - if (cursor != null && cursor.moveToFirst()) { - return new Pair<>(cursor.getLong(0), cursor.getLong(1) == 1); - } - - return new Pair<>(-1L, false); - } finally { - if (cursor != null) cursor.close(); - } - } - - public long getLastUpdated(long threadId) { - SQLiteDatabase db = getReadableDatabase(); - Cursor cursor = db.query(TABLE_NAME, new String[]{THREAD_CREATION_DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); - - try { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getLong(0); - } - - return -1L; - } finally { - if (cursor != null) cursor.close(); - } + return ThreadDatabaseExtKt.queryThreads(this, addresses); } public int getMessageCount(long threadId) { @@ -686,213 +396,8 @@ public long getOrCreateThreadIdFor(Address address) { return null; } - public void setHasSent(long threadId, boolean hasSent) { - ContentValues contentValues = new ContentValues(1); - final int hasSentValue = hasSent ? 1 : 0; - contentValues.put(HAS_SENT, hasSentValue); - - if (getWritableDatabase().update(TABLE_NAME, contentValues, ID + " = ? AND " + HAS_SENT + " != ?", - new String[] {String.valueOf(threadId), String.valueOf(hasSentValue)}) > 0) { - notifyThreadUpdated(threadId); - } - } - - - public boolean update(long threadId, boolean unarchive) { - long count = mmsSmsDatabase.get().getConversationCount(threadId); - - try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.get().readerFor(mmsSmsDatabase.get().getConversationSnippet(threadId))) { - MessageRecord record = null; - if (reader != null) { - record = reader.getNext(); - while (record != null && record.isDeleted()) { - record = reader.getNext(); - } - } - - if (record != null && !record.isDeleted()) { - updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), record.getMessageContent(), - record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), - record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); - return false; - } else { - // for empty threads or if there is only deleted messages, show an empty snippet - clearSnippet(threadId); - return false; - } - } finally { - notifyThreadUpdated(threadId); - } - } - - public boolean isRead(long threadId) { - SQLiteDatabase db = getReadableDatabase(); - // Only ask for the "READ" column - String[] projection = {READ}; - String selection = ID + " = ?"; - String[] args = {String.valueOf(threadId)}; - - Cursor cursor = db.query(TABLE_NAME, projection, selection, args, null, null, null); - try { - if (cursor != null && cursor.moveToFirst()) { - // READ is stored as 1 = read, 0 = unread - return cursor.getInt(0) == 1; - } - return false; - } finally { - if (cursor != null) cursor.close(); - } - } - - /** - * @param threadId - * @param lastSeenTime - * @param force - * @param updateNotifications - if true, update the notification state. Set to false if you already came from a notification interaction - * @return true if we have set the last seen for the thread, false if there were no messages in the thread - */ - public boolean markAllAsRead(long threadId, long lastSeenTime, boolean force, boolean updateNotifications) { - if (mmsSmsDatabase.get().getConversationCount(threadId) <= 0 && !force) return false; - List messages = setRead(threadId, lastSeenTime); - markReadProcessor.get().process(messages); - if(updateNotifications) messageNotifier.get().updateNotification(context, threadId); - return setLastSeen(threadId, lastSeenTime); - } - - private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) { - if (messageRecord.isMms()) { - MmsMessageRecord record = (MmsMessageRecord) messageRecord; - String attachmentString = record.getSlideDeck().getBody(); - if (!attachmentString.isEmpty()) { - if (!messageRecord.getBody().isEmpty()) { - attachmentString = attachmentString + ": " + messageRecord.getBody(); - } - return attachmentString; - } - } - return messageRecord.getBody(); - } - - private @Nullable Uri getAttachmentUriFor(MessageRecord record) { - if (!record.isMms() || record.isMmsNotification()) return null; - - SlideDeck slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); - Slide thumbnail = slideDeck.getThumbnailSlide(); - - if (thumbnail != null) { - return thumbnail.getThumbnailUri(); - } - - return null; - } - - private @NonNull String createQuery(@NonNull String where) { - String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); - return "SELECT " + projection + " FROM " + TABLE_NAME + - " LEFT OUTER JOIN " + RecipientSettingsDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientSettingsDatabase.TABLE_NAME + "." + RecipientSettingsDatabase.COL_ADDRESS + - " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + - " LEFT OUTER JOIN " + LokiMessageDatabase.groupInviteTable + - " ON "+ TABLE_NAME + "." + ID + " = " + LokiMessageDatabase.groupInviteTable+"."+LokiMessageDatabase.invitingSessionId + - " WHERE " + where; - } - - public void notifyThreadUpdated(long threadId) { + void notifyThreadUpdated(long threadId) { Log.d(TAG, "Notifying thread updated: " + threadId); updateNotifications.tryEmit(threadId); } - - private class Reader implements Closeable { - - private final Cursor cursor; - - public Reader(Cursor cursor) { - this.cursor = cursor; - } - - public int getCount() { - return cursor == null ? 0 : cursor.getCount(); - } - - public ThreadRecord getNext() { - if (cursor == null || !cursor.moveToNext()) - return null; - - return getCurrent(); - } - - public ThreadRecord getCurrent() { - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID)); - Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.ADDRESS))); - - Recipient recipient = recipientRepository.get().getRecipientSync(address); - String body = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET)); - long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.THREAD_CREATION_DATE)); - long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT)); - int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT)); - int unreadMentionCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_MENTION_COUNT)); - long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE)); - int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS)); - int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT)); - int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT)); - long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); - String invitingAdmin = cursor.getString(cursor.getColumnIndexOrThrow(LokiMessageDatabase.invitingSessionId)); - String messageContentJson = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT)); - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { - readReceiptCount = 0; - } - - MessageRecord lastMessage = null; - - if (count > 0) { - lastMessage = mmsSmsDatabase.get().getLastMessage(threadId, false, false); - } - - final GroupThreadStatus groupThreadStatus; - if (recipient.isGroupV2Recipient()) { - GroupInfo.ClosedGroupInfo group = ConfigFactoryProtocolKt.getGroup( - configFactory.get(), - new AccountId(recipient.getAddress().toString()) - ); - if (group != null && group.getDestroyed()) { - groupThreadStatus = GroupThreadStatus.Destroyed; - } else if (group != null && group.getKicked()) { - groupThreadStatus = GroupThreadStatus.Kicked; - } else { - groupThreadStatus = GroupThreadStatus.None; - } - } else { - groupThreadStatus = GroupThreadStatus.None; - } - - final boolean isUnread = address instanceof Address.Conversable && - ConfigFactoryProtocolKt.withUserConfigs(configFactory.get(), configs -> - SharedConfigUtilsKt.getConversationUnread( - configs.getConvoInfoVolatile(), (Address.Conversable) address)); - - MessageContent messageContent; - try { - messageContent = (messageContentJson == null || messageContentJson.isEmpty()) ? null : json.decodeFromString( - MessageContent.Companion.serializer(), - messageContentJson - ); - } catch (Exception e) { - Log.e(TAG, "Failed to parse message content for thread: " + threadId, e); - messageContent = null; - } - - return new ThreadRecord(body, lastMessage, recipient, date, count, - unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type, - lastSeen, readReceiptCount, invitingAdmin, groupThreadStatus, messageContent, isUnread); - } - - @Override - public void close() { - if (cursor != null) { - cursor.close(); - } - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabaseExt.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabaseExt.kt new file mode 100644 index 0000000000..0ee1c19ef4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabaseExt.kt @@ -0,0 +1,222 @@ +package org.thoughtcrime.securesms.database + +import android.database.sqlite.SQLiteDoneException +import androidx.collection.LongLongMap +import androidx.collection.MutableLongLongMap +import androidx.collection.MutableLongSet +import androidx.collection.mutableLongSetOf +import androidx.core.database.getStringOrNull +import androidx.sqlite.db.transaction +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.recipients.RecipientData +import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.util.asSequence +import kotlin.time.Instant + +fun ThreadDatabase.queryThreads(addresses: Collection): List { + val addressAsJson = json.encodeToString(addresses) + + //language=roomsql + return readableDatabase.query( + """ + SELECT + ${ThreadDatabase.ID}, + ${ThreadDatabase.ADDRESS}, + + -- Query the groupInviteTable to find out who invited the user to this group + (SELECT ${LokiMessageDatabase.invitingSessionId} FROM ${LokiMessageDatabase.groupInviteTable} WHERE ${LokiMessageDatabase.threadID} = threads.${ThreadDatabase.ID} LIMIT 1) AS invitingAdminId, + + -- Count unread sms + ( + SELECT COUNT(*) + FROM ${SmsDatabase.TABLE_NAME} s + WHERE s.${SmsDatabase.THREAD_ID} = threads.${ThreadDatabase.ID} + AND ${SmsDatabase.DATE_SENT} > ${ThreadDatabase.LAST_SEEN} + AND NOT s.${MmsSmsColumns.IS_OUTGOING} + AND NOT s.${MmsSmsColumns.IS_DELETED} + ) AS smsUnreadCount, + + -- Count unread sms with mention + ( + SELECT COUNT(*) + FROM ${SmsDatabase.TABLE_NAME} s + WHERE s.${SmsDatabase.THREAD_ID} = threads.${ThreadDatabase.ID} + AND ${SmsDatabase.DATE_SENT} > ${ThreadDatabase.LAST_SEEN} + AND s.${SmsDatabase.HAS_MENTION} + AND NOT s.${MmsSmsColumns.IS_OUTGOING} + AND NOT s.${MmsSmsColumns.IS_DELETED} + ) AS smsUnreadMentionCount, + + -- Count unread mms + ( + SELECT COUNT(*) + FROM ${MmsDatabase.TABLE_NAME} m + WHERE m.${MmsSmsColumns.THREAD_ID} = threads.${ThreadDatabase.ID} + AND ${MmsDatabase.DATE_SENT} > ${ThreadDatabase.LAST_SEEN} + AND NOT m.${MmsSmsColumns.IS_OUTGOING} + AND NOT m.${MmsSmsColumns.IS_DELETED} + ) AS mmsUnreadCount, + + -- Count unread mms with mention + ( + SELECT COUNT(*) + FROM ${MmsDatabase.TABLE_NAME} m + WHERE m.${MmsSmsColumns.THREAD_ID} = threads.${ThreadDatabase.ID} + AND ${MmsDatabase.DATE_SENT} > ${ThreadDatabase.LAST_SEEN} + AND m.${MmsSmsColumns.HAS_MENTION} + AND NOT m.${MmsSmsColumns.IS_OUTGOING} + AND NOT m.${MmsSmsColumns.IS_DELETED} + ) AS mmsUnreadMentionCount, + + -- Count sms + ( + SELECT COUNT(*) + FROM ${SmsDatabase.TABLE_NAME} s + WHERE s.${SmsDatabase.THREAD_ID} = threads.${ThreadDatabase.ID} + ) AS smsCount, + + -- Count mms + ( + SELECT COUNT(*) + FROM ${MmsDatabase.TABLE_NAME} m + WHERE m.${MmsSmsColumns.THREAD_ID} = threads.${ThreadDatabase.ID} + ) AS mmsCount + FROM ${ThreadDatabase.TABLE_NAME} AS threads + WHERE ${ThreadDatabase.ADDRESS} IN (SELECT value FROM json_each(?)) + """, arrayOf(addressAsJson)).use { cursor -> + cursor.asSequence() + .mapTo(ArrayList(cursor.count)) { cursor -> + val threadId = cursor.getLong(0) + val threadAddress = cursor.getString(1).toAddress() as Address.Conversable + val invitingAdminId = cursor.getStringOrNull(2) + val smsUnreadCount = cursor.getLong(3) + val smsUnreadMentionCount = cursor.getLong(4) + val mmsUnreadCount = cursor.getLong(5) + val mmsUnreadMentionCount = cursor.getLong(6) + val smsCount = cursor.getLong(7) + val mmsCount = cursor.getLong(8) + + val threadRecipient = recipientRepository.get().getRecipientSync(threadAddress) + val lastMessage = mmsSmsDatabase.get().getLastMessage( + /* threadId = */ threadId, + /* includeReactions = */ false, + /* getQuote = */ false + ) + + val date = when { + lastMessage != null -> lastMessage.dateReceived + threadRecipient.data is RecipientData.Contact -> threadRecipient.data.createdAt.toEpochMilli() + threadRecipient.data is RecipientData.Group -> threadRecipient.data.joinedAt.toEpochMilli() + else -> 0L + } + + ThreadRecord( + threadId = threadId, + recipient = threadRecipient, + lastMessage = lastMessage, + count = smsCount.toInt() + mmsCount.toInt(), + unreadCount = smsUnreadCount.toInt() + mmsUnreadCount.toInt(), + unreadMentionCount = smsUnreadMentionCount.toInt() + mmsUnreadMentionCount.toInt(), + isUnread = false, // This information is not stored in the db, you need to populate it from config + date = date, + invitingAdminId = invitingAdminId + ) + } + } +} + +fun ThreadDatabase.threadContainsOutgoingMessage(threadId: Long): Boolean { + //language=roomsql + val hasOutgoingSms = readableDatabase.rawQuery(""" + SELECT 1 FROM ${SmsDatabase.TABLE_NAME} + WHERE ${SmsDatabase.THREAD_ID} = ? + AND ${SmsDatabase.IS_OUTGOING} + AND NOT ${MmsSmsColumns.IS_DELETED} + LIMIT 1 + """, threadId).use { it.count > 0 } + + if (hasOutgoingSms) return true + + //language=roomsql + return readableDatabase.rawQuery(""" + SELECT 1 FROM ${MmsDatabase.TABLE_NAME} + WHERE ${MmsSmsColumns.THREAD_ID} = ? + AND ${MmsSmsColumns.IS_OUTGOING} + AND NOT ${MmsSmsColumns.IS_DELETED} + LIMIT 1 + """, threadId).use { it.count > 0 } +} + +fun ThreadDatabase.getLastSeen(address: Address.Conversable): Instant? { + return readableDatabase.query( + "SELECT ${ThreadDatabase.LAST_SEEN} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} = ?", + arrayOf(address.address) + ).use { cursor -> + if (cursor.moveToNext()) { + Instant.fromEpochMilliseconds(cursor.getLong(0)) + } else { + null + } + } +} + +fun ThreadDatabase.getAddressAndLastSeen(id: Long): Pair? { + return readableDatabase.query( + "SELECT ${ThreadDatabase.ADDRESS}, ${ThreadDatabase.LAST_SEEN} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ID} = ?", + arrayOf(id) + ).use { + if (it.moveToNext()) { + (it.getString(0).toAddress() as Address.Conversable) to it.getLong(1) + } else { + null + } + } +} + +fun ThreadDatabase.getAllLastSeen(): LongLongMap { + return readableDatabase.query( + "SELECT ${ThreadDatabase.ID}, ${ThreadDatabase.LAST_SEEN} FROM ${ThreadDatabase.TABLE_NAME}" + ).use { cursor -> + MutableLongLongMap(cursor.count).apply { + while (cursor.moveToNext()) { + set(cursor.getLong(0), cursor.getLong(1)) + } + } + } +} + +/** + * Update or create a thread record to store the given lastRead timestamp. + */ +fun ThreadDatabase.upsertThreadLastSeen(lastReads: Iterable>) { + var updatedThreadIDs: MutableLongSet? = null + + writableDatabase.compileStatement(""" + INSERT INTO ${ThreadDatabase.TABLE_NAME} (${ThreadDatabase.ADDRESS}, ${ThreadDatabase.LAST_SEEN}) + VALUES (?, ?) + ON CONFLICT (${ThreadDatabase.ADDRESS}) + DO UPDATE SET ${ThreadDatabase.LAST_SEEN} = EXCLUDED.${ThreadDatabase.LAST_SEEN} + WHERE ${ThreadDatabase.LAST_SEEN} != EXCLUDED.${ThreadDatabase.LAST_SEEN} + RETURNING ${ThreadDatabase.ID} + """).use { stmt -> + lastReads.forEach { (address, lastRead) -> + stmt.clearBindings() + stmt.bindString(1, address.address) + stmt.bindLong(2, lastRead.toEpochMilliseconds()) + + try { + val threadId = stmt.simpleQueryForLong() + if (updatedThreadIDs == null) { + updatedThreadIDs = mutableLongSetOf(threadId) + } else { + updatedThreadIDs.add(threadId) + } + } catch (_: SQLiteDoneException) { + // This happens when we don't have an update for the thread + } + } + } + + updatedThreadIDs?.forEach { notifyThreadUpdated(it) } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 770b33cce1..3213aacaf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -108,9 +108,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV56 = 77; private static final int lokiV57 = 78; private static final int lokiV58 = 79; + private static final int lokiV59 = 80; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV58; + private static final int DATABASE_VERSION = lokiV59; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -279,6 +280,9 @@ public void onCreate(SQLiteDatabase db) { RecipientSettingsDatabase.Companion.migrateProStatusToProData(db); SnodeDatabase.Companion.createTableAndMigrateData(db, true); + + SmsDatabase.addOutgoingColumn(db); + MmsDatabase.Companion.addOutgoingColumn(db); } @Override @@ -633,6 +637,11 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { SnodeDatabase.Companion.createTableAndMigrateData(db, true); } + if (oldVersion < lokiV59) { + SmsDatabase.addOutgoingColumn(db); + MmsDatabase.Companion.addOutgoingColumn(db); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index bbc4372315..c141c5e7b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -16,8 +16,6 @@ */ package org.thoughtcrime.securesms.database.model; -import android.content.Context; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -72,9 +70,6 @@ public abstract class DisplayRecord { public long getDateSent() { return dateSent; } public long getDateReceived() { return dateReceived; } public long getThreadId() { return threadId; } - public int getDeliveryStatus() { return deliveryStatus; } - public int getDeliveryReceiptCount() { return deliveryReceiptCount; } - public int getReadReceiptCount() { return readReceiptCount; } public boolean isDelivered() { return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE && diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 239de984d9..8ed4c1579b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -51,12 +51,13 @@ public MediaMmsMessageRecord(long id, Recipient conversationRecipient, @NonNull List linkPreviews, @NonNull List reactions, boolean hasMention, @Nullable MessageContent messageContent, - Set proFeatures) + Set proFeatures, + @Nullable String serverHash) { super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, - linkPreviews, reactions, hasMention, messageContent, proFeatures); + linkPreviews, reactions, hasMention, messageContent, proFeatures, serverHash); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt index 0beef4738a..52e78eb3db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt @@ -24,17 +24,6 @@ data class MessageId( } companion object { - /** - * Returns null for invalid IDs. Useful when pulling a possibly-unset ID from a database, or something like that. - */ - @JvmStatic - fun fromNullable(id: Long, mms: Boolean): MessageId? { - return if (id > 0) { - MessageId(id, mms) - } else { - null - } - } @JvmStatic fun deserialize(serialized: String): MessageId { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 9f45ac1317..e49fb1f1f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -45,6 +45,8 @@ public abstract class MessageRecord extends DisplayRecord { public final long id; private final List reactions; private final boolean hasMention; + @Nullable + private final String serverHash; @Nullable private UpdateMessageData groupUpdateMessage; @@ -64,7 +66,8 @@ public final MessageId getMessageId() { long expiresIn, long expireStarted, int readReceiptCount, List reactions, boolean hasMention, @Nullable MessageContent messageContent, - Set proFeatures) + Set proFeatures, + @Nullable String serverHash) { super(body, conversationRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount, messageContent); @@ -74,7 +77,8 @@ public final MessageId getMessageId() { this.expireStarted = expireStarted; this.reactions = reactions; this.hasMention = hasMention; - this.proFeatures = proFeatures; + this.proFeatures = proFeatures; + this.serverHash = serverHash; } public long getId() { @@ -95,6 +99,8 @@ public long getExpiresIn() { } public long getExpireStarted() { return expireStarted; } + public @Nullable String getServerHash() { return serverHash; } + public boolean getHasMention() { return hasMention; } public boolean isMediaPending() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 30e75f7475..366c07ec77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -29,8 +29,9 @@ public abstract class MmsMessageRecord extends MessageRecord { @Nullable Quote quote, @NonNull List linkPreviews, List reactions, boolean hasMention, @Nullable MessageContent messageContent, - Set proFeatures) { - super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, expiresIn, expireStarted, readReceiptCount, reactions, hasMention, messageContent, proFeatures); + Set proFeatures, + @Nullable String serverHash) { + super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, expiresIn, expireStarted, readReceiptCount, reactions, hasMention, messageContent, proFeatures, serverHash); this.slideDeck = slideDeck; this.quote = quote; this.linkPreviews.addAll(linkPreviews); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index 6cb467c35d..aa6d4f1529 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -17,6 +17,8 @@ package org.thoughtcrime.securesms.database.model; +import androidx.annotation.Nullable; + import org.session.libsession.utilities.recipients.Recipient; import java.util.List; @@ -41,11 +43,12 @@ public SmsMessageRecord(long id, int status, long expiresIn, long expireStarted, int readReceiptCount, List reactions, boolean hasMention, - Set proFeatures) { + Set proFeatures, + @Nullable String serverHash) { super(id, body, recipient, individualRecipient, dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, expiresIn, expireStarted, readReceiptCount, reactions, hasMention, null, - proFeatures); + proFeatures, serverHash); } public long getType() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java deleted file mode 100644 index 5ed38dc3ef..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (C) 2012 Moxie Marlinspike - * Copyright (C) 2013-2017 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.database.model; - -import static org.session.libsession.utilities.StringSubstitutionConstants.AUTHOR_KEY; -import static org.session.libsession.utilities.StringSubstitutionConstants.MESSAGE_SNIPPET_KEY; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.squareup.phrase.Phrase; - -import org.session.libsession.utilities.AddressKt; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.recipients.RecipientNamesKt; -import org.thoughtcrime.securesms.database.model.content.MessageContent; - -import network.loki.messenger.R; - -/** - * The message record model which represents thread heading messages. - * - * @author Moxie Marlinspike - * - */ -public class ThreadRecord extends DisplayRecord { - public @Nullable final MessageRecord lastMessage; - private final long count; - private final int unreadCount; - private final int unreadMentionCount; - private final long lastSeen; - private final String invitingAdminId; - private final boolean isUnread; - - @NonNull - public final GroupThreadStatus groupThreadStatus; - - public ThreadRecord(@NonNull String body, - @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount, - int unreadMentionCount, long threadId, int deliveryReceiptCount, int status, - long snippetType, - long lastSeen, int readReceiptCount, String invitingAdminId, - @NonNull GroupThreadStatus groupThreadStatus, - @Nullable MessageContent messageContent, - boolean isUnread) - { - super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount, messageContent); - this.lastMessage = lastMessage; - this.count = count; - this.unreadCount = unreadCount; - this.unreadMentionCount = unreadMentionCount; - this.lastSeen = lastSeen; - this.invitingAdminId = invitingAdminId; - this.groupThreadStatus = groupThreadStatus; - this.isUnread = isUnread; - } - - - public long getCount() { return count; } - - public int getUnreadCount() { return unreadCount; } - - public int getUnreadMentionCount() { return unreadMentionCount; } - - public long getDate() { return getDateReceived(); } - - public long getLastSeen() { return lastSeen; } - - public boolean isPinned() { return getRecipient().isPinned(); } - - public String getInvitingAdminId() { - return invitingAdminId; - } - - public boolean isUnread() { - return isUnread; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.kt new file mode 100644 index 0000000000..3a7aa644d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.database.model + +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientData + +data class ThreadRecord( + val lastMessage: MessageRecord?, + val threadId: Long, + val recipient: Recipient, + val count: Int, // total message count + val unreadCount: Int, // unread message count + val unreadMentionCount: Int, // unread mention count + val date: Long, + val isUnread: Boolean, + val invitingAdminId: String?, +) { + val isDelivered: Boolean + get() = lastMessage?.isDelivered == true + + val isFailed: Boolean + get() = lastMessage?.isFailed == true + + val isSent: Boolean + get() = lastMessage?.isSent == true + + val isPending: Boolean + get() = lastMessage?.isPending == true + + val isPinned: Boolean + get() = recipient.isPinned + + val isRead: Boolean + get() = lastMessage?.isRead == true + + val isOutgoing: Boolean + get() = lastMessage?.isOutgoing == true + + val groupThreadStatus: GroupThreadStatus + get() { + val group = recipient.data as? RecipientData.Group + + return when { + group?.kicked == true -> GroupThreadStatus.Kicked + group?.destroyed == true -> GroupThreadStatus.Destroyed + else -> GroupThreadStatus.None + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt index 7f64961212..16c2082bf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/OnAppStartupComponents.kt @@ -4,7 +4,6 @@ import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPollerManager import org.session.libsession.network.SnodeClock import org.thoughtcrime.securesms.auth.AuthAwareComponentsHandler -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.disguise.AppDisguiseManager import org.thoughtcrime.securesms.emoji.EmojiIndexLoader import org.thoughtcrime.securesms.groups.ExpiredGroupManager @@ -36,7 +35,6 @@ class OnAppStartupComponents private constructor( persistentLogger: PersistentLogger, appDisguiseManager: AppDisguiseManager, tokenFetcher: TokenFetcher, - threadDatabase: ThreadDatabase, emojiIndexLoader: EmojiIndexLoader, subscriptionCoordinator: SubscriptionCoordinator, authAwareHandler: AuthAwareComponentsHandler, @@ -54,7 +52,6 @@ class OnAppStartupComponents private constructor( persistentLogger, appDisguiseManager, tokenFetcher, - threadDatabase, emojiIndexLoader, subscriptionCoordinator, authAwareHandler, diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 705cce96de..e7ccbeaecc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.home -import android.content.Context import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater @@ -27,11 +26,10 @@ import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.GroupDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.ui.adaptive.getAdaptiveInfo +import org.thoughtcrime.securesms.repository.ConversationRepository import javax.inject.Inject @AndroidEntryPoint @@ -52,7 +50,7 @@ class ConversationOptionsBottomSheet() : BottomSheetDialogFragment(), View.OnCli @Inject lateinit var loginStateRepository: LoginStateRepository @Inject lateinit var groupManager : GroupManagerV2 - @Inject lateinit var threadDatabase: ThreadDatabase + @Inject lateinit var conversationRepository: ConversationRepository var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null @@ -92,9 +90,9 @@ class ConversationOptionsBottomSheet() : BottomSheetDialogFragment(), View.OnCli publicKey = requireNotNull(args.getString(ARG_PUBLIC_KEY)) requireNotNull(args.getLong(ARG_THREAD_ID)) val addressString = requireNotNull(args.getString(ARG_ADDRESS)) - val address = Address.fromSerialized(addressString) + val address = Address.fromSerialized(addressString) as Address.Conversable thread = requireNotNull( - threadDatabase.getThreads(listOf(address)).firstOrNull() + conversationRepository.getConversationList().firstOrNull { it.recipient.address == address } ) { "Thread not found for address: $addressString" } group = groupDatabase.getGroup(thread.recipient.address.toString()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index c5541df83e..6befeb2ed1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -72,6 +72,7 @@ import org.thoughtcrime.securesms.conversation.v3.ConversationActivityV3 import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabaseExt.getUnreadCount import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.ThreadRecord @@ -593,13 +594,15 @@ class HomeActivity : ScreenLockActionBarActivity(), private val GlobalSearchResult.messageResults: List get() { val unreadThreadMap = messages - .map { it.threadId }.toSet() + .asSequence() + .mapNotNull { it.conversationRecipient.address as? Address.Conversable } + .toSet() .associateWith { mmsSmsDatabase.getUnreadCount(it) } return messages.map { GlobalSearchAdapter.Model.Message( messageResult = it, - unread = unreadThreadMap[it.threadId] ?: 0, + unread = unreadThreadMap[it.conversationRecipient.address] ?: 0, isSelf = it.conversationRecipient.isLocalNumber, showProBadge = it.conversationRecipient.shouldShowProBadge ) @@ -826,7 +829,10 @@ class HomeActivity : ScreenLockActionBarActivity(), private fun markAllAsRead(thread: ThreadRecord) { lifecycleScope.launch(Dispatchers.Default) { - storage.markConversationAsRead(thread.threadId, clock.currentTimeMillis()) + storage.updateConversationLastSeenIfNeeded( + thread.recipient.address as Address.Conversable, + clock.currentTimeMillis() + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index 0749b6838e..febe979e27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -68,7 +68,6 @@ class HomeDiffUtil( oldItem.isDelivered == newItem.isDelivered && oldItem.isSent == newItem.isSent && oldItem.isPending == newItem.isPending && - oldItem.lastSeen == newItem.lastSeen && oldItem.isUnread == newItem.isUnread && old.isTyping == new.isTyping ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index 43214116cc..45f397ca8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -321,11 +321,6 @@ class MediaOverviewViewModel @AssistedInject constructor( threadDatabase.getThreadIdIfExistsFor(address.toString()) } - // Notify the content provider that the thread has been updated - if (threadId >= 0) { - threadDatabase.notifyThreadUpdated(threadId) - } - mutableShowingActionProgress.value = null mutableSelectedItemIDs.value = emptySet() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index 62173b3390..a19dc5a807 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -26,14 +26,8 @@ import androidx.core.app.NotificationManagerCompat; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MarkedMessageInfo; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; - -import java.util.LinkedList; -import java.util.List; +import org.session.libsession.network.SnodeClock; +import org.thoughtcrime.securesms.database.Storage; import javax.inject.Inject; @@ -50,9 +44,9 @@ public class AndroidAutoHeardReceiver extends BroadcastReceiver { public static final String THREAD_IDS_EXTRA = "car_heard_thread_ids"; public static final String NOTIFICATION_ID_EXTRA = "car_notification_id"; - @Inject MarkReadProcessor markReadProcessor; + @Inject Storage storage; @Inject MessageNotifier messageNotifier; - @Inject ThreadDatabase threadDb; + @Inject SnodeClock clock; @SuppressLint("StaticFieldLeak") @Override @@ -70,19 +64,14 @@ public void onReceive(final Context context, Intent intent) new AsyncTask() { @Override protected Void doInBackground(Void... params) { - List messageIdsCollection = new LinkedList<>(); - - for (long threadId : threadIds) { - Log.i(TAG, "Marking meassage as read: " + threadId); - List messageIds = threadDb.setRead(threadId, true); - - messageIdsCollection.addAll(messageIds); - } + long now = clock.currentTimeMillis(); + for (long threadId : threadIds) { + storage.updateConversationLastSeenIfNeeded(threadId, now); + } - messageNotifier.updateNotification(context); - markReadProcessor.process(messageIdsCollection); + messageNotifier.updateNotification(context); - return null; + return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt index 6e9c764009..e9a7d3d900 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.kt @@ -36,6 +36,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.pro.ProStatusManager @@ -62,7 +63,7 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { lateinit var messageNotifier: MessageNotifier @Inject - lateinit var markReadProcessor: MarkReadProcessor + lateinit var storage: Storage @Inject lateinit var messageSender: MessageSender @@ -122,8 +123,7 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { mmsDatabase.insertMessageOutbox( message = reply, threadId = replyThreadId, - forceSms = false, - runThreadUpdate = true + forceSms = false ) } catch (e: MmsException) { Log.w(TAG, e) @@ -140,15 +140,18 @@ class AndroidAutoReplyReceiver : BroadcastReceiver() { replyThreadId, reply, false, - snodeClock.currentTimeMillis(), - true + snodeClock.currentTimeMillis() ) } - val messageIds = threadDatabase.setRead(replyThreadId, true) + if (address is Address.Conversable) { + storage.updateConversationLastSeenIfNeeded( + threadAddress = address, + lastSeenTime = snodeClock.currentTimeMillis() + ) + } messageNotifier.updateNotification(context) - markReadProcessor.process(messageIds) return null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index 652c45371a..ae919f4112 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.NotifyType +import org.thoughtcrime.securesms.database.threadContainsOutgoingMessage import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.AvatarUtils @@ -167,7 +168,7 @@ class DefaultMessageNotifier @Inject constructor( if (recipient != null && !recipient.isGroupOrCommunityRecipient && threadDatabase.getMessageCount( threadId ) == 1 && - !(recipient.approved || threadDatabase.getLastSeenAndHasSent(threadId).second()) + !(recipient.approved || threadDatabase.threadContainsOutgoingMessage(threadId)) ) { removeHasHiddenMessageRequests(context) } @@ -589,7 +590,7 @@ class DefaultMessageNotifier @Inject constructor( // Handle message requests early val isMessageRequest = !threadRecipient.isGroupOrCommunityRecipient && !threadRecipient.approved && - !threadDatabase.getLastSeenAndHasSent(threadId).second() + !threadDatabase.threadContainsOutgoingMessage(threadId) // Do not repeat request notifications once the thread has >1 messages if (isMessageRequest) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.kt index 064b10c507..fe727e923c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.kt @@ -5,12 +5,8 @@ import android.content.Context import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier -import org.thoughtcrime.securesms.database.MmsDatabase -import org.thoughtcrime.securesms.database.SmsDatabase +import org.session.libsession.network.SnodeClock import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ManagerScope import javax.inject.Inject @@ -28,11 +24,8 @@ class DeleteNotificationReceiver : BroadcastReceiver() { @Inject @ManagerScope lateinit var scope: CoroutineScope - @Inject lateinit var messageNotifier: MessageNotifier - - @Inject lateinit var smsDb: SmsDatabase - @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var storage: Storage + @Inject lateinit var snodeClock: SnodeClock override fun onReceive(context: Context, intent: Intent) { if (intent.action != DELETE_NOTIFICATION_ACTION) return @@ -46,21 +39,12 @@ class DeleteNotificationReceiver : BroadcastReceiver() { val pending = goAsync() // extends the receiver's lifecycle scope.launch { try { - withContext(Dispatchers.IO) { - val now = System.currentTimeMillis() - for(threadId in threadIds){ - storage.markConversationAsRead( - threadId = threadId, - lastSeenTime = now, - force = false, - updateNotification = false - ) - } - - for (i in ids.indices) { - if (!mms[i]) smsDb.markAsNotified(ids[i]) - else mmsDb.markAsNotified(ids[i]) - } + val now = snodeClock.currentTimeMillis() + for (threadId in threadIds){ + storage.updateConversationLastSeenIfNeeded( + threadId = threadId, + lastSeenTime = now + ) } } finally { pending.finish() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt index 04bcd97df4..73521d7175 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -1,157 +1,434 @@ package org.thoughtcrime.securesms.notifications -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException +import androidx.collection.LongLongMap +import androidx.collection.LongObjectMap +import androidx.collection.MutableLongLongMap +import androidx.collection.MutableLongObjectMap import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch -import org.session.libsession.database.StorageProtocol -import org.session.libsession.database.userAuth +import kotlinx.coroutines.supervisorScope import org.session.libsession.messaging.messages.control.ReadReceipt import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.network.SnodeClock -import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled -import org.session.libsession.utilities.associateByNotNull -import org.session.libsession.utilities.isGroupOrCommunity -import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.database.userAuth +import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.RecipientData import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.api.snode.AlterTtlApi import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest import org.thoughtcrime.securesms.api.swarm.execute -import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType -import org.thoughtcrime.securesms.database.LokiMessageDatabase -import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.auth.AuthAwareComponent +import org.thoughtcrime.securesms.auth.LoggedInState +import org.thoughtcrime.securesms.database.MessageChanges import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabaseExt.findIncomingMessages +import org.thoughtcrime.securesms.database.MmsSmsDatabaseExt.getMessages import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.database.getAddressAndLastSeen +import org.thoughtcrime.securesms.database.getAllLastSeen +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.preferences.CommunicationPreferences +import org.thoughtcrime.securesms.preferences.PreferenceStorage import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException +/** + * This component reacts to changes to lastSeen for each thread and perform various logic + * upon it. Right now it handles: + * + * 1. Sending read receipt back to sender + * 2. Starting disappearing message logic for AFTER_READ mode + * + * Because the reactivity of this component, there is no need to manually perform read receipt sending, + * or disappearing message logic anywhere else in the code, this component will be able to + * handle them as changes arise. + */ +@Singleton class MarkReadProcessor @Inject constructor( - @param:ApplicationContext private val context: Context, private val recipientRepository: RecipientRepository, private val messageSender: MessageSender, private val mmsSmsDatabase: MmsSmsDatabase, private val mmsDatabase: MmsDatabase, private val smsDatabase: SmsDatabase, private val threadDb: ThreadDatabase, - private val storage: StorageProtocol, private val snodeClock: SnodeClock, - private val lokiMessageDatabase: LokiMessageDatabase, + private val prefs: PreferenceStorage, + private val storage: Storage, + private val alterTtlApiFactory: AlterTtlApi.Factory, private val swarmApiExecutor: SwarmApiExecutor, - private val alterTtyFactory: AlterTtlApi.Factory, - @param:ManagerScope private val coroutineScope: CoroutineScope, -) { - fun process( - markedReadMessages: List - ) { - if (markedReadMessages.isEmpty()) return + @param:ManagerScope private val scope: CoroutineScope, +) : AuthAwareComponent { + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = supervisorScope { + val threadLastSeenFlow = threadDb.updateNotifications + .map { id -> + threadDb.getAddressAndLastSeen(id)?.let { (address, lastSeen) -> + ThreadUpdated(id, address, lastSeen) + } + } + .filterNotNull() + .distinctUntilChanged() + .shareIn(this, SharingStarted.Lazily) - sendReadReceipts( - markedReadMessages = markedReadMessages - ) + val messageAddedFlow = merge( + mmsDatabase.changeNotification, + smsDatabase.changeNotification, + ).filter { it.changeType == MessageChanges.ChangeType.Added } + .shareIn(this, SharingStarted.Lazily) + launch { + try { + handleReadReceiptSending(threadLastSeenFlow, messageAddedFlow) + } catch (e: Throwable) { + Log.e(TAG, "Error handling read receipt sending", e) + if (e is CancellationException) throw e + } + } - // start disappear after read messages except TimerUpdates in groups. - markedReadMessages - .asSequence() - .filter { it.expiryType == ExpiryType.AFTER_READ } - .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { - (messageContent is DisappearingMessageUpdate) - && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunity == true } == false + launch { + try { + handleAfterReadDisappearingMessages(threadLastSeenFlow, messageAddedFlow) + } catch (e: Throwable) { + Log.e(TAG, "Error handling after read disappearing messages", e) + if (e is CancellationException) throw e } - .forEach { - val db = if (it.expirationInfo.id.mms) { - mmsDatabase - } else { - smsDatabase + } + } + + private data class ThreadUpdated( + val threadId: Long, + val threadAddress: Address.Conversable, + val lastSeenMs: Long + ) + + private data class State( + val lastSeenByThreadIDs: LongLongMap, + val updates: T? = null, + ) + + /** + * Look for messages that need sending read receipt to, when the read receipt is enabled. + */ + private suspend fun handleReadReceiptSending( + threadLastSeenFlow: SharedFlow, + messageAddedFlow: SharedFlow + ) { + class Updates(val threadAddress: Address, val messageTimestamps: List) + + @Suppress("OPT_IN_USAGE") + prefs.watch(scope, CommunicationPreferences.READ_RECEIPT_ENABLED) + .flatMapLatest { enabled -> + if (!enabled) { + Log.d(TAG, "Read receipts disabled, skipping") + return@flatMapLatest emptyFlow() } - db.markExpireStarted(it.expirationInfo.id.id, snodeClock.currentTimeMillis()) + /** + * The flow below bases on a state (the [State]), and accept two events: + * 1. Thread last seen updated + * 2. Message added + * + * When "1. Thread last seen updated": query all the messages between old last seen and + * new last seen to figure out which messages are newly eligible for sending read receipt. + * + * When "2. Message added": look at the added messages and check if they should + * be regarded as eligible for sending read receipt, by comparing to current state. + * + * The end result is the [Updates] which contains the message timestamps that need to send + * receipt to. + * + * There are other nuisances in the flow where we try not to query db unnecessarily + * when we don't do read receipts for those threads anyway. + */ + merge(threadLastSeenFlow, messageAddedFlow,) + .scan(State(threadDb.getAllLastSeen())) { acc, event -> + when (event) { + is MessageChanges -> { + State( + lastSeenByThreadIDs = acc.lastSeenByThreadIDs, + updates = threadDb.getRecipientForThreadId(event.threadId) + ?.takeIf(::eligibleForReadReceipt) + ?.let { threadAddress -> + val threadLastSeen = + acc.lastSeenByThreadIDs.getOrDefault( + event.threadId, + 0L + ) + mmsSmsDatabase.getMessages(event.ids) + .mapNotNull { msg -> + msg.dateSent.takeIf { + msg.eligibleForReadReceipt( + threadLastSeen + ) + } + } + .takeIf { it.isNotEmpty() } + ?.also { + Log.d( + TAG, + "New message(s) in thread ${event.threadId} eligible for read receipt" + ) + } + ?.let { Updates(threadAddress, it) } + } + ) + } + + is ThreadUpdated -> { + // Thread updated, look at the last seen to determine if we are truly updated + val oldLastSeen = + acc.lastSeenByThreadIDs.getOrDefault(event.threadId, 0L) + + if (event.lastSeenMs > oldLastSeen) { + Log.d( + TAG, + "Thread ${event.threadId} lastSeen advanced $oldLastSeen -> ${event.lastSeenMs}" + ) + State( + lastSeenByThreadIDs = acc.lastSeenByThreadIDs.updated( + event.threadId, + event.lastSeenMs + ), + updates = if (eligibleForReadReceipt(event.threadAddress)) { + mmsSmsDatabase.findIncomingMessages( + event.threadId, + oldLastSeen, + event.lastSeenMs + ).mapNotNull { msg -> + msg.dateSent.takeIf { + msg.eligibleForReadReceipt( + event.lastSeenMs + ) + } + }.takeIf { it.isNotEmpty() } + ?.also { + Log.d( + TAG, + "Sending read receipt for ${it.size} message(s) in thread ${event.threadId}" + ) + } + ?.let { Updates(event.threadAddress, it) } + } else { + Log.d( + TAG, + "Thread ${event.threadId} not eligible for read receipt, skipping" + ) + null + } + ) + } else if (acc.updates != null) { + acc.copy(updates = null) + } else { + acc + } + } + + else -> error("Unexpected event type $event") + } + }.mapNotNull { it.updates } } + // Must NOT use collectLatest as "updates" data is an "event" rather than a state: it + // does not persist between emissions. Using collectLatest will potentially cause + // data loss. + .collect { updates -> + Log.d(TAG, "Sending read receipts to ${updates.messageTimestamps.size} messages") - hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> - GlobalScope.launch { - try { - shortenExpiryOfDisappearingAfterRead(hashToMessages) - } catch (e: Exception) { - Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) + val message = ReadReceipt(updates.messageTimestamps).apply { + sentTimestamp = snodeClock.currentTimeMillis() } + + messageSender.send(message, updates.threadAddress) } - } } - private fun hashToDisappearAfterReadMessage( - context: Context, - markedReadMessages: List - ): Map? { - return markedReadMessages - .filter { it.expiryType == ExpiryType.AFTER_READ } - .associateByNotNull { it.expirationInfo.run { lokiMessageDatabase.getMessageServerHash(id) } } - .takeIf { it.isNotEmpty() } + private fun eligibleForReadReceipt(threadAddress: Address): Boolean { + if (threadAddress is Address.GroupLike) { + // Read receipts don't get sent to any group like conversations + return false + } + + val recipient = recipientRepository.getRecipientSync(threadAddress) + + return (recipient.data as? RecipientData.Contact)?.let { + it.approved && !it.blocked + } == true } - private fun shortenExpiryOfDisappearingAfterRead( - hashToMessage: Map + private suspend fun handleAfterReadDisappearingMessages( + threadLastSeenFlow: SharedFlow, + messageAddedFlow: SharedFlow, ) { - coroutineScope.launch { - val userAuth = checkNotNull(storage.userAuth) { "No authorized user" } - - hashToMessage.entries - .groupBy( - keySelector = { it.value.expirationInfo.expiresIn }, - valueTransform = { it.key } - ).forEach { (expiresIn, hashes) -> - try { - swarmApiExecutor.execute( - SwarmApiRequest( - swarmPubKeyHex = userAuth.accountId.hexString, - api = alterTtyFactory.create( - messageHashes = hashes, - auth = userAuth, - alterType = AlterTtlApi.AlterType.Shorten, - newExpiry = snodeClock.currentTimeMillis() + expiresIn - ) + merge(threadLastSeenFlow, messageAddedFlow) + .scan(State(threadDb.getAllLastSeen())) { acc, event -> + when (event) { + is MessageChanges -> { + if (threadDb.getRecipientForThreadId(event.threadId) is Address.GroupLike) { + if (acc.updates != null) acc.copy(updates = null) else acc + } else { + val threadLastSeen = acc.lastSeenByThreadIDs.getOrDefault(event.threadId, 0L) + val eligible = mmsSmsDatabase.getMessages(event.ids) + .filter { it.eligibleForAfterReadExpiry(threadLastSeen) } + State( + lastSeenByThreadIDs = acc.lastSeenByThreadIDs, + updates = eligible.toExpiryUpdates(snodeClock.currentTimeMillis()) + ?.also { Log.d(TAG, "New message(s) in thread ${event.threadId} eligible for AFTER_READ expiry") } ) - ) - } catch (e: Throwable) { - if (e is CancellationException) throw e + } + } - Log.e(TAG, "Failed to shorten expiry for messages with hashes $hashes", e) + is ThreadUpdated -> { + if (event.threadAddress is Address.GroupLike) { + if (acc.updates != null) acc.copy(updates = null) else acc + } else { + val oldLastSeen = acc.lastSeenByThreadIDs.getOrDefault(event.threadId, 0L) + if (event.lastSeenMs > oldLastSeen) { + val eligible = mmsSmsDatabase.findIncomingMessages( + event.threadId, + oldLastSeen, + event.lastSeenMs + ).filter { it.eligibleForAfterReadExpiry(event.lastSeenMs) } + State( + lastSeenByThreadIDs = acc.lastSeenByThreadIDs.updated( + event.threadId, + event.lastSeenMs + ), + updates = eligible.toExpiryUpdates(snodeClock.currentTimeMillis()) + ?.also { Log.d(TAG, "Starting AFTER_READ expiry for ${it.messageIds.size} message(s) in thread ${event.threadId}") } + ) + } else if (acc.updates != null) { + acc.copy(updates = null) + } else { + acc + } + } } + + else -> error("Unknown event type $event") } - } + }.mapNotNull { it.updates } + .collect { updates -> + Log.d(TAG, "Marking expiry started for ${updates.messageIds.size} message(s) at ${updates.expireStarted}") + for (messageId in updates.messageIds) { + if (messageId.mms) { + mmsDatabase.markExpireStarted(messageId.id, updates.expireStarted) + } else { + smsDatabase.markExpireStarted(messageId.id, updates.expireStarted) + } + } + + scope.launch { + shortenExpiry(updates) + } + } } - private val Recipient.shouldSendReadReceipt: Boolean - get() = when (data) { - is RecipientData.Contact -> approved && !blocked - else -> false - } + /** + * Shortens the swarm-side TTL of AFTER_READ messages to match their local expiry time, + * so they disappear from the network at the same time as locally. + */ + private suspend fun shortenExpiry(updates: ExpiryUpdates) { + if (updates.hashesByExpiry.isEmpty()) return + val userAuth = storage.userAuth ?: return - private fun sendReadReceipts( - markedReadMessages: List - ) { - if (!isReadReceiptsEnabled(context)) return - - markedReadMessages.map { it.syncMessageId } - .filter { recipientRepository.getRecipientSync(it.address).shouldSendReadReceipt } - .groupBy { it.address } - .forEach { (address, messages) -> - messages.map { it.timetamp } - .let(::ReadReceipt) - .apply { sentTimestamp = snodeClock.currentTimeMillis() } - .let { messageSender.send(it, address) } + updates.hashesByExpiry.forEach { expiresIn, hashes -> + try { + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = alterTtlApiFactory.create( + messageHashes = hashes, + auth = userAuth, + alterType = AlterTtlApi.AlterType.Shorten, + newExpiry = updates.expireStarted + expiresIn, + ) + ) + ) + Log.d(TAG, "Shortened TTL for ${hashes.size} message(s), new expiry at ${updates.expireStarted + expiresIn}") + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to shorten TTL for ${hashes.size} message(s)", e) } + } } + // messageIds: for markExpireStarted; hashesByExpiry: expiresIn -> hashes for TTL shortening + private class ExpiryUpdates( + val messageIds: List, + val hashesByExpiry: LongObjectMap>, + val expireStarted: Long, + ) + companion object { + private fun List.toExpiryUpdates(expireStarted: Long): ExpiryUpdates? { + if (isEmpty()) return null + val hashesByExpiry = MutableLongObjectMap>() + for (msg in this) { + val hash = msg.serverHash + if (hash != null) { + hashesByExpiry.getOrPut(msg.expiresIn) { ArrayList() }.add(hash) + } + } + @Suppress("UNCHECKED_CAST") + return ExpiryUpdates( + messageIds = map { it.messageId }, + hashesByExpiry = hashesByExpiry as LongObjectMap>, + expireStarted = expireStarted + ) + } + + private fun MessageRecord.eligibleForReadReceipt(maxSentTimeMsInclusive: Long): Boolean { + return isIncoming && !isControlMessage && dateSent <= maxSentTimeMsInclusive + } + + /** + * Determines whether this message should have its expiry timer started as a result of + * the thread being read up to [lastSeenMs]. Group threads are excluded entirely at the + * call site, as they don't support AFTER_READ mode. + * + * The AFTER_READ expiry mode is encoded in the message columns rather than as an explicit + * mode field: [MessageRecord.expiresIn] > 0 means expiry is configured, and + * [MessageRecord.expireStarted] == 0 means the timer hasn't started (i.e. AFTER_READ). + * AFTER_SEND messages already have expireStarted = sentTimestamp on insertion, so they are + * implicitly excluded. + * + * Control message exceptions are handled at insertion time: MessageRequestResponse is + * inserted with expiresIn = 0; CallMessage is coerced to AFTER_SEND so expireStarted != 0. + * + * Only incoming messages are handled here; outgoing timers are started by + * [org.thoughtcrime.securesms.service.ExpiringMessageManager] at send time. + */ + private fun MessageRecord.eligibleForAfterReadExpiry(lastSeenMs: Long): Boolean { + return isIncoming && expiresIn > 0 && expireStarted == 0L && dateSent <= lastSeenMs + } + + // Copy the existing map and add the new item + private fun LongLongMap.updated(key: Long, value: Long): LongLongMap { + val map = MutableLongLongMap(size + if (containsKey(key)) 0 else 1) + map.putAll(this) + map[key] = value + return map + } + + private const val TAG = "MarkReadProcessor" } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 5f98b954dd..991e9288de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -5,11 +5,14 @@ import android.content.Context import android.content.Intent import androidx.core.app.NotificationManagerCompat import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol import org.session.libsession.network.SnodeClock +import org.session.libsession.utilities.Address import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.ManagerScope import javax.inject.Inject @AndroidEntryPoint @@ -20,19 +23,26 @@ class MarkReadReceiver : BroadcastReceiver() { @Inject lateinit var clock: SnodeClock + @Inject + lateinit var threadDatabase: ThreadDatabase + + @Inject + @ManagerScope + lateinit var scope: CoroutineScope + override fun onReceive(context: Context, intent: Intent) { if (CLEAR_ACTION != intent.action) return val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)) - GlobalScope.launch { + + scope.launch { val currentTime = clock.currentTimeMillis() threadIds.forEach { Log.i(TAG, "Marking as read: $it") - storage.markConversationAsRead( - threadId = it, + storage.updateConversationLastSeenIfNeeded( + threadAddress = threadDatabase.getRecipientForThreadId(it) as? Address.Conversable ?: return@forEach, lastSeenTime = currentTime, - force = true ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt index 27ff5975d8..d269291c9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.kt @@ -47,7 +47,6 @@ import javax.inject.Inject */ @AndroidEntryPoint class RemoteReplyReceiver : BroadcastReceiver() { - @Inject lateinit var threadDatabase: ThreadDatabase @Inject @@ -68,9 +67,6 @@ class RemoteReplyReceiver : BroadcastReceiver() { @Inject lateinit var recipientRepository: RecipientRepository - @Inject - lateinit var markReadProcessor: MarkReadProcessor - @Inject lateinit var messageSender: MessageSender @@ -122,8 +118,7 @@ class RemoteReplyReceiver : BroadcastReceiver() { mmsDatabase.insertMessageOutbox( message = reply, threadId = threadId, - forceSms = false, - runThreadUpdate = true + forceSms = false ), true ) messageSender.send(message, address) @@ -144,18 +139,21 @@ class RemoteReplyReceiver : BroadcastReceiver() { threadId, reply, false, - System.currentTimeMillis(), - true + System.currentTimeMillis() ), false ) messageSender.send(message, address) } } - val messageIds = threadDatabase.setRead(threadId, true) + if (address is Address.Conversable) { + storage.updateConversationLastSeenIfNeeded( + threadAddress = address, + lastSeenTime = clock.currentTimeMillis() + ) + } messageNotifier.updateNotification(context) - markReadProcessor.process(messageIds) return null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CommunicationPreferences.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/CommunicationPreferences.kt new file mode 100644 index 0000000000..6c2701af21 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CommunicationPreferences.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.preferences + +object CommunicationPreferences { + val READ_RECEIPT_ENABLED: PreferenceKey = PreferenceKey.boolean("pref_read_receipts") +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SharedPreferenceStorage.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SharedPreferenceStorage.kt index 4ca6c75186..19544b2500 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SharedPreferenceStorage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SharedPreferenceStorage.kt @@ -29,7 +29,7 @@ class SharedPreferenceStorage @AssistedInject constructor( @Assisted private val prefs: SharedPreferences, private val json: Json, ) : PreferenceStorage { - private val changes = MutableSharedFlow>() + private val changes = MutableSharedFlow>(extraBufferCapacity = 10) private val cache = LruCache>(100) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/compose/PrivacySettingsPreferenceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/compose/PrivacySettingsPreferenceViewModel.kt index da63239f2f..e46cfd76c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/compose/PrivacySettingsPreferenceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/compose/PrivacySettingsPreferenceViewModel.kt @@ -21,13 +21,14 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED import org.session.libsession.utilities.TextSecurePreferences.Companion.DISABLE_PASSPHRASE_PREF import org.session.libsession.utilities.TextSecurePreferences.Companion.INCOGNITO_KEYBOARD_PREF -import org.session.libsession.utilities.TextSecurePreferences.Companion.READ_RECEIPTS_PREF import org.session.libsession.utilities.TextSecurePreferences.Companion.SCREEN_LOCK import org.session.libsession.utilities.TextSecurePreferences.Companion.TYPING_INDICATORS import org.session.libsession.utilities.TextSecurePreferences.Companion.LINK_PREVIEWS import org.session.libsession.utilities.observeBooleanKey import org.session.libsession.utilities.withMutableUserConfigs import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.preferences.CommunicationPreferences +import org.thoughtcrime.securesms.preferences.PreferenceStorage import org.thoughtcrime.securesms.preferences.compose.PrivacySettingsPreferenceViewModel.Commands.ShowCallsWarningDialog import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder.Companion.areNotificationsEnabled @@ -36,6 +37,7 @@ import javax.inject.Inject @HiltViewModel class PrivacySettingsPreferenceViewModel @Inject constructor( private val prefs: TextSecurePreferences, + private val prefStorage: PreferenceStorage, private val configFactory: ConfigFactory, private val app: Application, private val typingStatusRepository: TypingStatusRepository, @@ -64,7 +66,7 @@ class PrivacySettingsPreferenceViewModel @Inject constructor( CALL_NOTIFICATIONS_ENABLED, SCREEN_LOCK, "community_message_requests", - READ_RECEIPTS_PREF, + CommunicationPreferences.READ_RECEIPT_ENABLED.name, TYPING_INDICATORS, LINK_PREVIEWS, INCOGNITO_KEYBOARD_PREF @@ -87,7 +89,7 @@ class PrivacySettingsPreferenceViewModel @Inject constructor( private val togglesFlow = combine( prefs.observeBooleanKey(CALL_NOTIFICATIONS_ENABLED, default = false), - prefs.observeBooleanKey(READ_RECEIPTS_PREF, default = false), + prefStorage.watch(viewModelScope, CommunicationPreferences.READ_RECEIPT_ENABLED), prefs.observeBooleanKey(TYPING_INDICATORS, default = false), prefs.observeBooleanKey(LINK_PREVIEWS, default = false), prefs.observeBooleanKey(INCOGNITO_KEYBOARD_PREF, default = false), @@ -215,7 +217,7 @@ class PrivacySettingsPreferenceViewModel @Inject constructor( } is Commands.ToggleReadReceipts -> { - prefs.setReadReceiptsEnabled(command.isEnabled) + prefStorage[CommunicationPreferences.READ_RECEIPT_ENABLED] = command.isEnabled } is Commands.ToggleTypingIndicators -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt index d772ade0d5..18ebe1d712 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.repository +import androidx.collection.MutableIntList +import androidx.collection.mutableIntListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -53,6 +55,7 @@ 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.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.RecipientSettingsDatabase @@ -65,6 +68,7 @@ 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 org.thoughtcrime.securesms.util.get import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton @@ -78,6 +82,7 @@ class DefaultConversationRepository @Inject constructor( private val communityDatabase: CommunityDatabase, private val draftDb: DraftDatabase, private val smsDb: SmsDatabase, + private val mmsDb: MmsDatabase, private val mmsSmsDb: MmsSmsDatabase, private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, @@ -173,6 +178,8 @@ class DefaultConversationRepository @Inject constructor( recipientDatabase.changeNotification.filter { it in allAddresses }, communityDatabase.changeNotification.filter { it in allAddresses }, threadDb.updateNotifications, + smsDb.changeNotification, + mmsDb.changeNotification, // If pro status pref changes, the convo is likely needing changes too TextSecurePreferences.Companion.events.filter { it == TextSecurePreferences.Companion.SET_FORCE_OTHER_USERS_PRO || @@ -185,13 +192,44 @@ class DefaultConversationRepository @Inject constructor( } .map { addresses -> withContext(Dispatchers.Default) { - threadDb.getThreads(addresses) + threadDb.getThreads(addresses).populateUnreadStatus() } } } override fun getConversationList(): List { - return threadDb.getThreads(getConversationListAddresses()) + return threadDb.getThreads(getConversationListAddresses()).populateUnreadStatus() + } + + /** + * + */ + private fun List.populateUnreadStatus(): List { + var recordIndicesWithUnreadStatus: MutableIntList? = null + + configFactory.withUserConfigs { configs -> + forEachIndexed { index, record -> + if (configs.convoInfoVolatile.get(record.recipient.address as Address.Conversable)?.unread == true) { + if (recordIndicesWithUnreadStatus == null) { + recordIndicesWithUnreadStatus = mutableIntListOf(index) + } else { + recordIndicesWithUnreadStatus.add(index) + } + } + } + } + + // No record has unread status, no need to change anything + if (recordIndicesWithUnreadStatus == null) { + return this + } + + // Some record have unread status, make a copy of the list and copy of those items + val copied = this.toMutableList() + recordIndicesWithUnreadStatus.forEach { index -> + copied[index] = copied[index].copy(isUnread = true) + } + return copied } override fun saveDraft(threadId: Long, text: String) { @@ -243,8 +281,7 @@ class DefaultConversationRepository @Inject constructor( contactThreadId, outgoingTextMessage, false, - message.sentTimestamp!!, - true + message.sentTimestamp!! ), false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt index 16936eea0d..4fafc9d50a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -1,21 +1,28 @@ package org.thoughtcrime.securesms.service import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withTimeoutOrNull import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.network.SnodeClock 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.MessageExpirationManagerProtocol import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.api.snode.AlterTtlApi +import org.thoughtcrime.securesms.api.swarm.SwarmApiExecutor +import org.thoughtcrime.securesms.api.swarm.SwarmApiRequest +import org.thoughtcrime.securesms.api.swarm.execute import org.thoughtcrime.securesms.auth.AuthAwareComponent import org.thoughtcrime.securesms.auth.LoggedInState import org.thoughtcrime.securesms.auth.LoginStateRepository @@ -24,13 +31,15 @@ import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.RecipientRepository 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.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.mms.MmsException import java.io.IOException import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.milliseconds private val TAG = ExpiringMessageManager::class.java.simpleName @@ -51,14 +60,16 @@ class ExpiringMessageManager @Inject constructor( private val storage: Lazy, private val loginStateRepository: LoginStateRepository, private val recipientRepository: RecipientRepository, - private val threadDatabase: ThreadDatabase, + private val alterTtlApiFactory: AlterTtlApi.Factory, + private val swarmApiExecutor: SwarmApiExecutor, + @param:ManagerScope private val scope: CoroutineScope, ) : MessageExpirationManagerProtocol, AuthAwareComponent { override suspend fun doWhileLoggedIn(loggedInState: LoggedInState) { supervisorScope { - launch { processDatabase(smsDatabase) } - launch { processDatabase(mmsDatabase) } + launch { processDatabase(smsDatabase, smsDatabase.changeNotification) } + launch { processDatabase(mmsDatabase, mmsDatabase.changeNotification) } } } @@ -71,7 +82,7 @@ class ExpiringMessageManager @Inject constructor( val sentTimestamp = message.sentTimestamp val groupAddress = message.groupPublicKey?.toAddress() as? Address.GroupLike val expiresInMillis = message.expiryMode.expiryMillis - val address = fromSerialized(senderPublicKey!!) + val address = senderPublicKey!!.toAddress() var recipient = recipientRepository.getRecipientSync(address) // if the sender is blocked, we don't display the update, except if it's in a closed group @@ -100,13 +111,13 @@ class ExpiringMessageManager @Inject constructor( dataExtractionNotification = null ) //insert the timer update message - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId) ?.let { MessageId(it.messageId, mms = true) } } catch (ioe: IOException) { - Log.e("Loki", "Failed to insert expiration update message.") + Log.e(TAG, "Failed to insert expiration update message.") null } catch (ioe: MmsException) { - Log.e("Loki", "Failed to insert expiration update message.") + Log.e(TAG, "Failed to insert expiration update message.") null } } @@ -118,7 +129,8 @@ class ExpiringMessageManager @Inject constructor( val groupId = message.groupPublicKey?.toAddress() as? Address.GroupLike val duration = message.expiryMode.expiryMillis try { - val serializedAddress = groupId ?: (message.syncTarget ?: message.recipient!!).toAddress() + val serializedAddress = + groupId ?: (message.syncTarget ?: message.recipient!!).toAddress() message.threadID = storage.get().getOrCreateThreadIdFor(serializedAddress) val content = DisappearingMessageUpdate(message.expiryMode) @@ -151,14 +163,13 @@ class ExpiringMessageManager @Inject constructor( return mmsDatabase.insertSecureDecryptedMessageOutbox( timerUpdateMessage, message.threadID!!, - sentTimestamp, - true + sentTimestamp )?.messageId?.let { MessageId(it, mms = true) } } catch (ioe: MmsException) { - Log.e("Loki", "Failed to insert expiration update message.", ioe) + Log.e(TAG, "Failed to insert expiration update message.", ioe) return null } catch (ioe: IOException) { - Log.e("Loki", "Failed to insert expiration update message.", ioe) + Log.e(TAG, "Failed to insert expiration update message.", ioe) return null } } @@ -184,8 +195,12 @@ class ExpiringMessageManager @Inject constructor( // they've done reading it. val messageId = message.id if (message.expiryMode != ExpiryMode.NONE && messageId != null) { - getDatabase(messageId.mms) - .markExpireStarted(messageId.id, clock.currentTimeMillis()) + val expireStarted = clock.currentTimeMillis() + getDatabase(messageId.mms).markExpireStarted(messageId.id, expireStarted) + val hash = message.serverHash + if (hash != null) { + scope.launch { shortenTtl(hash, expireStarted, message.expiryMode.expiryMillis) } + } } } @@ -197,18 +212,48 @@ class ExpiringMessageManager @Inject constructor( // If we receive a message that is sent from ourselves (aka the sync message), we // will start the expiry timer regardless if (message.expiryMode is ExpiryMode.AfterSend || - (message.expiryMode != ExpiryMode.NONE && message.isSenderSelf)) { - getDatabase(messageId.mms) - .markExpireStarted(messageId.id, message.sentTimestamp!!) + (message.expiryMode != ExpiryMode.NONE && message.isSenderSelf) + ) { + val expireStarted = message.sentTimestamp!! + getDatabase(messageId.mms).markExpireStarted(messageId.id, expireStarted) + val hash = message.serverHash + if (hash != null) { + scope.launch { shortenTtl(hash, expireStarted, message.expiryMode.expiryMillis) } + } + } + } + + private suspend fun shortenTtl(hash: String, expireStarted: Long, expiresIn: Long) { + val userAuth = storage.get().userAuth ?: return + + try { + swarmApiExecutor.execute( + SwarmApiRequest( + swarmPubKeyHex = userAuth.accountId.hexString, + api = alterTtlApiFactory.create( + messageHashes = listOf(hash), + auth = userAuth, + alterType = AlterTtlApi.AlterType.Shorten, + newExpiry = expireStarted + expiresIn, + ) + ) + ) + Log.d(TAG, "Shortened TTL for message hash $hash, new expiry at ${expireStarted + expiresIn}") + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to shorten TTL for message hash $hash", e) } } - private suspend fun processDatabase(db: MessagingDatabase) { + private suspend fun processDatabase(db: MessagingDatabase, dbChanges: SharedFlow<*>) { while (true) { val expiredMessages = db.getExpiredMessageIDs(clock.currentTimeMillis()) if (expiredMessages.isNotEmpty()) { - Log.d(TAG, "Deleting ${expiredMessages.size} expired messages from ${db.javaClass.simpleName}") + Log.d( + TAG, + "Deleting ${expiredMessages.size} expired messages from ${db.javaClass.simpleName}" + ) for (messageId in expiredMessages) { try { db.deleteMessage(messageId) @@ -225,16 +270,21 @@ class ExpiringMessageManager @Inject constructor( continue // Proceed to the next iteration if the next expiration is already or about go to in the past } - val dbChanges = threadDatabase.updateNotifications - if (nextExpiration > 0) { val delayMills = nextExpiration - now - Log.d(TAG, "Wait for up to $delayMills ms for next expiration in ${db.javaClass.simpleName}") - withTimeoutOrNull(delayMills) { - dbChanges.first() - } + Log.d( + TAG, + "Wait for up to $delayMills ms for next expiration in ${db.javaClass.simpleName}" + ) + @Suppress("OPT_IN_USAGE") + dbChanges.timeout(delayMills.milliseconds) + .catch { emit(Unit) } + .first() } else { - Log.d(TAG, "No next expiration found, waiting for any change in ${db.javaClass.simpleName}") + Log.d( + TAG, + "No next expiration found, waiting for any change in ${db.javaClass.simpleName}" + ) // If there are no next expiration, just wait for any change in the database dbChanges.first() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt index b685a86725..dc629928b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ReadReceiptManager.kt @@ -6,12 +6,14 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.preferences.CommunicationPreferences +import org.thoughtcrime.securesms.preferences.PreferenceStorage import javax.inject.Inject import javax.inject.Singleton @Singleton class ReadReceiptManager @Inject constructor( - private val textSecurePreferences: TextSecurePreferences, + private val prefs: PreferenceStorage, private val mmsSmsDatabase: MmsSmsDatabase, ): ReadReceiptManagerProtocol { @@ -20,14 +22,17 @@ class ReadReceiptManager @Inject constructor( sentTimestamps: List, readTimestamp: Long ) { - if (textSecurePreferences.isReadReceiptsEnabled()) { - + if (prefs[CommunicationPreferences.READ_RECEIPT_ENABLED]) { // Redirect message to master device conversation - var address = Address.fromSerialized(fromRecipientId) + val address = Address.fromSerialized(fromRecipientId) for (timestamp in sentTimestamps) { - Log.i("Loki", "Received encrypted read receipt: (XXXXX, $timestamp)") + Log.i(TAG, "Received encrypted read receipt: (XXXXX, $timestamp)") mmsSmsDatabase.incrementReadReceiptCount(SyncMessageId(address, timestamp), readTimestamp) } } } + + companion object { + private const val TAG = "ReadReceiptManager" + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt index ab6ab6a111..cd08d2cc28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -1,27 +1,90 @@ package org.thoughtcrime.securesms.util +import network.loki.messenger.libsession_util.MutableConversationVolatileConfig import network.loki.messenger.libsession_util.ReadableConversationVolatileConfig +import network.loki.messenger.libsession_util.util.Conversation import org.session.libsession.utilities.Address +import org.session.libsession.utilities.MutableUserConfigs +fun ReadableConversationVolatileConfig.get(address: Address.Conversable): Conversation? { + return when (address) { + is Address.Standard -> { + getOneToOne(address.accountId.hexString) + } + + is Address.Group -> { + getClosedGroup(address.accountId.hexString) + } + + is Address.LegacyGroup -> { + getLegacyClosedGroup(address.groupPublicKeyHex) + } -fun ReadableConversationVolatileConfig.getConversationUnread(recipientAddress: Address.Conversable): Boolean { - return when (recipientAddress) { + is Address.Community -> { + getCommunity(baseUrl = address.serverUrl, room = address.room) + } + + is Address.CommunityBlindedId -> { + getBlindedOneToOne(address.blindedId.address) + } + } +} + +fun MutableConversationVolatileConfig.erase(address: Address.Conversable) { + when (address) { is Address.Standard -> { - getOneToOne(recipientAddress.accountId.hexString)?.unread == true + eraseOneToOne(address.accountId.hexString) } is Address.Group -> { - getClosedGroup(recipientAddress.accountId.hexString)?.unread == true + eraseClosedGroup(address.accountId.hexString) } is Address.LegacyGroup -> { - getLegacyClosedGroup(recipientAddress.groupPublicKeyHex)?.unread == true + eraseLegacyClosedGroup(address.groupPublicKeyHex) } is Address.Community -> { - getCommunity(baseUrl = recipientAddress.serverUrl, room = recipientAddress.room)?.unread == true + eraseCommunity(baseUrl = address.serverUrl, room = address.room) } - is Address.CommunityBlindedId -> false + is Address.CommunityBlindedId -> { + eraseBlindedOneToOne(address.blindedId.address) + } } } + +fun MutableUserConfigs.getOrConstructConvo(address: Address.Conversable): Conversation { + return when (address) { + is Address.Standard -> { + convoInfoVolatile.getOrConstructOneToOne(address.accountId.hexString) + } + + is Address.Group -> { + convoInfoVolatile.getOrConstructClosedGroup(address.accountId.hexString) + } + + is Address.LegacyGroup -> { + convoInfoVolatile.getOrConstructLegacyGroup(address.groupPublicKeyHex) + } + + is Address.Community -> { + val community = requireNotNull( + userGroups.getCommunityInfo( + baseUrl = address.serverUrl, + room = address.room + ) + ) { "Community does not exist" } + + convoInfoVolatile.getOrConstructCommunity( + baseUrl = address.serverUrl, + room = address.room, + pubKeyHex = community.community.pubKeyHex + ) + } + + is Address.CommunityBlindedId -> { + convoInfoVolatile.getOrConstructedBlindedOneToOne(address.blindedId.address) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index f083632ab4..100655fbfb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -718,8 +718,10 @@ class CallManager @Inject constructor( } fun insertCallMessage( - threadPublicKey: String, callMessageType: CallMessageType, - expiryMode: ExpiryMode, sentTimestamp: Long = snodeClock.currentTimeMillis() + threadPublicKey: String, + callMessageType: CallMessageType, + expiryMode: ExpiryMode, + sentTimestamp: Long = snodeClock.currentTimeMillis() ) { storage.insertCallMessage(threadPublicKey, callMessageType, sentTimestamp, expiryMode) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt index 6fc497500f..ad0ba64ae6 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import network.loki.messenger.R import network.loki.messenger.libsession_util.util.Bytes +import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import org.junit.Rule @@ -246,16 +247,12 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(Recipient( address = STANDARD_ADDRESS, data = RecipientData.Contact( - name = "Contact", - nickname = null, - avatar = null, - approved = true, - approvedMe = true, - blocked = false, - expiryMode = ExpiryMode.NONE, - priority = 1, - proData = null, - profileUpdatedAt = null + configData = Contact( + id = "contact-id", + name = "Contact", + expiryMode = ExpiryMode.NONE + ), + proData = null ) ) ) @@ -300,16 +297,12 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(Recipient( address = STANDARD_ADDRESS, data = RecipientData.Contact( - name = "Contact", - nickname = null, - avatar = null, - approved = true, - approvedMe = true, - blocked = false, - expiryMode = ExpiryMode.AfterSend(time.inWholeSeconds), - priority = 1, + configData = Contact( + id = "contact-id", + name = "Contact", + expiryMode = ExpiryMode.AfterSend(time.inWholeSeconds), + ), proData = null, - profileUpdatedAt = null ) ) ) @@ -361,16 +354,12 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(Recipient( address = STANDARD_ADDRESS, data = RecipientData.Contact( - name = "Contact", - nickname = null, - avatar = null, - approved = true, - approvedMe = true, - blocked = false, - expiryMode = ExpiryMode.AfterSend(time.inWholeSeconds), - priority = 1, + configData = Contact( + id = "contact-id", + name = "Contact", + expiryMode = ExpiryMode.AfterSend(time.inWholeSeconds), + ), proData = null, - profileUpdatedAt = null ) ) ) @@ -422,16 +411,12 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(Recipient( address = STANDARD_ADDRESS, data = RecipientData.Contact( - name = "Contact", - nickname = null, - avatar = null, - approved = true, - approvedMe = true, - blocked = false, - expiryMode = ExpiryMode.AfterRead(time.inWholeSeconds), - priority = 1, + configData = Contact( + id = "contact-id", + name = "Contact", + expiryMode = ExpiryMode.AfterRead(time.inWholeSeconds), + ), proData = null, - profileUpdatedAt = null ) ) ) @@ -485,16 +470,11 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(Recipient( address = STANDARD_ADDRESS, data = RecipientData.Contact( - name = "Contact", - nickname = null, - avatar = null, - approved = true, - approvedMe = true, - blocked = false, - expiryMode = ExpiryMode.AfterRead(time.inWholeSeconds), - priority = 1, + configData = Contact( + id = "contact-id", + expiryMode = ExpiryMode.AfterRead(time.inWholeSeconds), + ), proData = null, - profileUpdatedAt = null ) ) ) @@ -564,7 +544,6 @@ class DisappearingMessagesViewModelTest : BaseViewModelTest() { context = application, disappearingMessages = disappearingMessages, navigator = navigator, - isNewConfigEnabled = true, showDebugOptions = false, recipientRepository = mock { onBlocking { getRecipient(recipient.address) } doReturn recipient diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 67a717557f..79316f3f49 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.ExpiryMode import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat @@ -64,16 +65,17 @@ class ConversationViewModelTest : BaseViewModelTest() { private val standardRecipient = Recipient( address = STANDARD_ADDRESS, data = RecipientData.Contact( - name = "Test User", - nickname = "Test User", - avatar = null, - approved = true, - approvedMe = true, - blocked = false, - expiryMode = ExpiryMode.NONE, - 1, + configData = Contact( + id = "contact-1", + name = "Test User", + nickname = "Test User", + approved = true, + approvedMe = true, + blocked = false, + expiryMode = ExpiryMode.NONE, + createdEpochSeconds = System.currentTimeMillis() / 1000L, + ), proData = null, - profileUpdatedAt = null ) ) @@ -136,6 +138,12 @@ class ConversationViewModelTest : BaseViewModelTest() { loginStateRepository = mock(), audioPlaybackManager = mock(), jobQueue = mock(), + mmsDatabase = mock { + on { changeNotification } doReturn MutableSharedFlow() + }, + smsDatabase = mock { + on { changeNotification } doReturn MutableSharedFlow() + }, ) }