diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 5dbddd8caa..251d443d5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -65,6 +65,7 @@ import org.signal.core.util.toInt import org.signal.core.util.toOptional import org.signal.core.util.toSingleLine import org.signal.core.util.update +import org.signal.core.util.withFtsSecureDelete import org.signal.core.util.withinTransaction import org.signal.libsignal.protocol.IdentityKey import org.thoughtcrime.securesms.attachments.Attachment @@ -2440,40 +2441,41 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat private fun markAsRemoteDeleteInternal(messageId: Long, deletedBy: RecipientId) { var deletedAttachments = false - writableDatabase.withinTransaction { db -> - db.update(TABLE_NAME) - .values( - DELETED_BY to deletedBy.toLong(), - BODY to null, - QUOTE_BODY to null, - QUOTE_AUTHOR to null, - QUOTE_TYPE to null, - QUOTE_ID to null, - LINK_PREVIEWS to null, - SHARED_CONTACTS to null, - ORIGINAL_MESSAGE_ID to null, - LATEST_REVISION_ID to null, - STARRED to 0 - ) - .where("$ID = ?", messageId) - .run() + writableDatabase.withFtsSecureDelete(SearchTable.FTS_TABLE_NAME) { + writableDatabase.withinTransaction { db -> + db.update(TABLE_NAME) + .values( + DELETED_BY to deletedBy.toLong(), + BODY to null, + QUOTE_BODY to null, + QUOTE_AUTHOR to null, + QUOTE_TYPE to null, + QUOTE_ID to null, + LINK_PREVIEWS to null, + SHARED_CONTACTS to null, + ORIGINAL_MESSAGE_ID to null, + LATEST_REVISION_ID to null, + STARRED to 0 + ) + .where("$ID = ?", messageId) + .run() - deletedAttachments = attachments.deleteAttachmentsForMessage(messageId) - mentions.deleteMentionsForMessage(messageId) - SignalDatabase.messageLog.deleteAllRelatedToMessage(messageId) - reactions.deleteReactions(MessageId(messageId)) - deleteGroupStoryReplies(messageId) - disassociateStoryQuotes(messageId) - polls.deletePoll(messageId) - disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId)) - disassociatePinnedMessage(messageId) + deletedAttachments = attachments.deleteAttachmentsForMessage(messageId) + mentions.deleteMentionsForMessage(messageId) + SignalDatabase.messageLog.deleteAllRelatedToMessage(messageId) + reactions.deleteReactions(MessageId(messageId)) + deleteGroupStoryReplies(messageId) + disassociateStoryQuotes(messageId) + polls.deletePoll(messageId) + disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId)) + disassociatePinnedMessage(messageId) - val threadId = getThreadIdForMessage(messageId) - threads.update(threadId, false) - notifyConversationListeners(threadId) + val threadId = getThreadIdForMessage(messageId) + threads.update(threadId, false) + notifyConversationListeners(threadId) + } } - OptimizeMessageSearchIndexJob.enqueue() AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId)) AppDependencies.databaseObserver.notifyConversationListListeners() @@ -3916,33 +3918,35 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat collectionOperator: SqlUtil.CollectionOperator ): Int { var rowsDeleted = 0 - val threadIds: Set = writableDatabase.withinTransaction { - SqlUtil.buildCollectionQuery( - column = ID, - values = messageIds, - prefix = "$IS_CALL_TYPE_CLAUSE AND ", - collectionOperator = collectionOperator - ).map { query -> - val threadSet = writableDatabase.select(THREAD_ID) - .from(TABLE_NAME) - .where(query.where, query.whereArgs) - .run() - .readToSet { cursor -> - cursor.requireLong(THREAD_ID) + val threadIds: Set = writableDatabase.withFtsSecureDelete(SearchTable.FTS_TABLE_NAME) { + writableDatabase.withinTransaction { + SqlUtil.buildCollectionQuery( + column = ID, + values = messageIds, + prefix = "$IS_CALL_TYPE_CLAUSE AND ", + collectionOperator = collectionOperator + ).map { query -> + val threadSet = writableDatabase.select(THREAD_ID) + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .readToSet { cursor -> + cursor.requireLong(THREAD_ID) + } + + val rows = writableDatabase + .delete(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + + if (rows <= 0) { + Log.w(TAG, "Failed to delete some rows during call update deletion.") } - val rows = writableDatabase - .delete(TABLE_NAME) - .where(query.where, query.whereArgs) - .run() - - if (rows <= 0) { - Log.w(TAG, "Failed to delete some rows during call update deletion.") - } - - rowsDeleted += rows - threadSet - }.flatten().toSet() + rowsDeleted += rows + threadSet + }.flatten().toSet() + } } threadIds.forEach { @@ -4027,7 +4031,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } @VisibleForTesting - fun deleteMessage(messageId: Long, threadId: Long, notify: Boolean = true, updateThread: Boolean = true): Boolean { + fun deleteMessage(messageId: Long, threadId: Long, notify: Boolean = true, updateThread: Boolean = true, skipSecureDelete: Boolean = false): Boolean { Log.d(TAG, "deleteMessage($messageId)") attachments.deleteAttachmentsForMessage(messageId) @@ -4037,10 +4041,20 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat disassociatePinnedMessage(messageId) reassignCollapsedHead(messageId) - writableDatabase - .delete(TABLE_NAME) - .where("$ID = ?", messageId) - .run() + val deletionOperation = { + writableDatabase + .delete(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + } + + if (skipSecureDelete) { + deletionOperation() + } else { + writableDatabase.withFtsSecureDelete(SearchTable.FTS_TABLE_NAME) { + deletionOperation() + } + } calls.updateCallEventDeletionTimestamps() threads.setLastScrolled(threadId, 0) @@ -4055,7 +4069,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat notifyConversationListeners(threadId) notifyStickerListeners() notifyStickerPackListeners() - OptimizeMessageSearchIndexJob.enqueue() if (updateThread) { notifyConversationListListeners() @@ -4468,28 +4481,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val threads = mutableSetOf() val unhandled = mutableListOf() - for (message in messagesToDelete) { - readableDatabase - .select(ID, THREAD_ID) - .from(TABLE_NAME) - .where("$DATE_SENT = ? AND $FROM_RECIPIENT_ID = ?", message.timetamp, message.recipientId) - .run() - .use { - if (it.moveToFirst()) { - val messageId = it.requireLong(ID) - val threadId = it.requireLong(THREAD_ID) + writableDatabase.withFtsSecureDelete(SearchTable.FTS_TABLE_NAME) { + for (message in messagesToDelete) { + readableDatabase + .select(ID, THREAD_ID) + .from(TABLE_NAME) + .where("$DATE_SENT = ? AND $FROM_RECIPIENT_ID = ?", message.timetamp, message.recipientId) + .run() + .use { + if (it.moveToFirst()) { + val messageId = it.requireLong(ID) + val threadId = it.requireLong(THREAD_ID) - deleteMessage( - messageId = messageId, - threadId = threadId, - notify = false, - updateThread = false - ) - threads += threadId - } else { - unhandled += message + deleteMessage( + messageId = messageId, + threadId = threadId, + notify = false, + updateThread = false, + skipSecureDelete = true + ) + threads += threadId + } else { + unhandled += message + } } - } + } } flushBulkDeleteNotifications(threads) diff --git a/core/util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt b/core/util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt index cbdca9ee0d..d9ad501805 100644 --- a/core/util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt +++ b/core/util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt @@ -36,6 +36,24 @@ inline fun T.withinTransaction(block: (T) -> R): } } +/** + * Runs [block] with the FTS5 "secure-delete" option enabled on [ftsTable], guaranteeing it is turned back off afterwards. + * + * Secure delete is slow, so we keep it off by default, but can selectively enable it for smaller delete operations. + * + * https://www.sqlite.org/fts5.html#the_secure_delete_configuration_option + */ +inline fun SupportSQLiteDatabase.withFtsSecureDelete(ftsTable: String, block: () -> R): R { + return this.withinTransaction { + execSQL("INSERT INTO $ftsTable ($ftsTable, rank) VALUES('secure-delete', 1)") + try { + block() + } finally { + execSQL("INSERT INTO $ftsTable ($ftsTable, rank) VALUES('secure-delete', 0)") + } + } +} + fun SupportSQLiteDatabase.getTableRowCount(table: String): Int { return this.query("SELECT COUNT(*) FROM $table").use { if (it.moveToFirst()) {