From d74b302edb7f6d09fe0e6f0efdcdf4bb7b710647 Mon Sep 17 00:00:00 2001 From: Clark Date: Thu, 21 Dec 2023 12:13:13 -0500 Subject: [PATCH] Add remaining non-group update messages for backup. --- .../securesms/backup/v2/BackupTest.kt | 88 ++++++++++++- .../securesms/backup/v2/BackupRepository.kt | 12 ++ .../v2/database/CallTableBackupExtensions.kt | 123 ++++++++++++++++++ .../v2/database/ChatItemExportIterator.kt | 96 ++++++++++++++ .../v2/database/ChatItemImportInserter.kt | 82 +++++++++++- .../database/MessageTableBackupExtensions.kt | 2 +- .../v2/processor/CallLogBackupProcessor.kt | 35 +++++ .../securesms/database/CallTable.kt | 12 +- .../securesms/database/MessageTable.kt | 2 +- app/src/main/protowire/Backup.proto | 3 +- .../main/java/org/signal/core/util/SqlUtil.kt | 2 +- 11 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/CallLogBackupProcessor.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt index f8998ebeb2..07c5024bbd 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2 import android.content.ContentValues import android.database.Cursor +import androidx.core.content.contentValuesOf import net.zetetic.database.sqlcipher.SQLiteDatabase import org.junit.Before import org.junit.Test @@ -20,8 +21,10 @@ import org.signal.core.util.requireLong import org.signal.core.util.requireString import org.signal.core.util.select import org.signal.core.util.toInt +import org.signal.core.util.withinTransaction import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore +import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.EmojiSearchTable import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.MessageTypes @@ -60,7 +63,8 @@ class BackupTest { /** Columns that we don't need to check equality of */ private val IGNORED_COLUMNS: Map> = mapOf( - RecipientTable.TABLE_NAME to setOf(RecipientTable.STORAGE_SERVICE_ID) + RecipientTable.TABLE_NAME to setOf(RecipientTable.STORAGE_SERVICE_ID), + MessageTable.TABLE_NAME to setOf(MessageTable.FROM_DEVICE_ID) ) /** Tables we don't need to check equality of */ @@ -145,6 +149,88 @@ class BackupTest { } } + @Test + fun individualCallLogs() { + backupTest { + val aliceId = individualRecipient( + aci = ALICE_ACI, + pni = ALICE_PNI, + e164 = ALICE_E164, + givenName = "Alice", + familyName = "Smith", + username = "alice.99", + hidden = false, + registeredState = RecipientTable.RegisteredState.REGISTERED, + profileKey = ProfileKey(Random.nextBytes(32)), + profileSharing = true, + hideStory = false + ) + insertOneToOneCallVariations(1, 1, aliceId) + } + } + + private fun insertOneToOneCallVariations(callId: Long, timestamp: Long, id: RecipientId): Long { + val directions = arrayOf(CallTable.Direction.INCOMING, CallTable.Direction.OUTGOING) + val callTypes = arrayOf(CallTable.Type.AUDIO_CALL, CallTable.Type.VIDEO_CALL) + val events = arrayOf( + CallTable.Event.MISSED, + CallTable.Event.OUTGOING_RING, + CallTable.Event.ONGOING, + CallTable.Event.ACCEPTED, + CallTable.Event.NOT_ACCEPTED + ) + var callTimestamp: Long = timestamp + var currentCallId = callId + for (direction in directions) { + for (event in events) { + for (type in callTypes) { + insertOneToOneCall(callId = currentCallId, callTimestamp, id, type, direction, event) + callTimestamp++ + currentCallId++ + } + } + } + + return currentCallId + } + + private fun insertOneToOneCall(callId: Long, timestamp: Long, peer: RecipientId, type: CallTable.Type, direction: CallTable.Direction, event: CallTable.Event) { + val messageType: Long = CallTable.Call.getMessageType(type, direction, event) + + SignalDatabase.rawDatabase.withinTransaction { + val recipient = Recipient.resolved(peer) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient) + val outgoing = direction == CallTable.Direction.OUTGOING + + val messageValues = contentValuesOf( + MessageTable.FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else peer.serialize(), + MessageTable.FROM_DEVICE_ID to 1, + MessageTable.TO_RECIPIENT_ID to if (outgoing) peer.serialize() else Recipient.self().id.serialize(), + MessageTable.DATE_RECEIVED to timestamp, + MessageTable.DATE_SENT to timestamp, + MessageTable.READ to 1, + MessageTable.TYPE to messageType, + MessageTable.THREAD_ID to threadId + ) + + val messageId = SignalDatabase.rawDatabase.insert(MessageTable.TABLE_NAME, null, messageValues) + + val values = contentValuesOf( + CallTable.CALL_ID to callId, + CallTable.MESSAGE_ID to messageId, + CallTable.PEER to peer.serialize(), + CallTable.TYPE to CallTable.Type.serialize(type), + CallTable.DIRECTION to CallTable.Direction.serialize(direction), + CallTable.EVENT to CallTable.Event.serialize(event), + CallTable.TIMESTAMP to timestamp + ) + + SignalDatabase.rawDatabase.insert(CallTable.TABLE_NAME, null, values) + + SignalDatabase.threads.update(threadId, true) + } + } + @Test fun accountData() { val context = ApplicationDependencies.getApplication() diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 0d33852be3..2d373b707c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -12,6 +12,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor +import org.thoughtcrime.securesms.backup.v2.processor.CallLogBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor @@ -70,6 +71,11 @@ object BackupRepository { eventTimer.emit("thread") } + CallLogBackupProcessor.export { frame -> + writer.write(frame) + eventTimer.emit("call") + } + ChatItemBackupProcessor.export { frame -> writer.write(frame) eventTimer.emit("message") @@ -131,6 +137,11 @@ object BackupRepository { eventTimer.emit("chat") } + frame.call != null -> { + CallLogBackupProcessor.import(frame.call, backupState) + eventTimer.emit("call") + } + frame.chatItem != null -> { chatItemInserter.insert(frame.chatItem) eventTimer.emit("chatItem") @@ -214,4 +225,5 @@ class BackupState { val chatIdToLocalThreadId = HashMap() val chatIdToLocalRecipientId = HashMap() val chatIdToBackupRecipientId = HashMap() + val callIdToType = HashMap() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt new file mode 100644 index 0000000000..04bb76f661 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import androidx.core.content.contentValuesOf +import org.signal.core.util.isNull +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.select +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.proto.Call +import org.thoughtcrime.securesms.database.CallTable +import org.thoughtcrime.securesms.database.RecipientTable +import java.io.Closeable + +typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call + +fun CallTable.getCallsForBackup(): CallLogIterator { + return CallLogIterator( + readableDatabase + .select() + .from(CallTable.TABLE_NAME) + .where("${CallTable.EVENT} != ${CallTable.Event.serialize(CallTable.Event.DELETE)}") + .run() + ) +} + +fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupState) { + val type = when (call.type) { + Call.Type.VIDEO_CALL -> CallTable.Type.VIDEO_CALL + Call.Type.AUDIO_CALL -> CallTable.Type.AUDIO_CALL + Call.Type.AD_HOC_CALL -> CallTable.Type.AD_HOC_CALL + Call.Type.GROUP_CALL -> CallTable.Type.GROUP_CALL + Call.Type.UNKNOWN_TYPE -> return + } + + val event = when (call.event) { + Call.Event.DELETE -> CallTable.Event.DELETE + Call.Event.JOINED -> CallTable.Event.JOINED + Call.Event.GENERIC_GROUP_CALL -> CallTable.Event.GENERIC_GROUP_CALL + Call.Event.DECLINED -> CallTable.Event.DECLINED + Call.Event.ACCEPTED -> CallTable.Event.ACCEPTED + Call.Event.MISSED -> CallTable.Event.MISSED + Call.Event.OUTGOING_RING -> CallTable.Event.OUTGOING_RING + Call.Event.OUTGOING -> CallTable.Event.ONGOING + Call.Event.NOT_ACCEPTED -> CallTable.Event.NOT_ACCEPTED + Call.Event.UNKNOWN_EVENT -> return + } + + val direction = if (call.outgoing) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING + + backupState.callIdToType[call.callId] = CallTable.Call.getMessageType(type, direction, event) + + val values = contentValuesOf( + CallTable.CALL_ID to call.callId, + CallTable.PEER to backupState.backupToLocalRecipientId[call.conversationRecipientId]!!.serialize(), + CallTable.TYPE to CallTable.Type.serialize(type), + CallTable.DIRECTION to CallTable.Direction.serialize(direction), + CallTable.EVENT to CallTable.Event.serialize(event), + CallTable.TIMESTAMP to call.timestamp + ) + + writableDatabase.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values) +} + +/** + * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. + * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. + */ +class CallLogIterator(private val cursor: Cursor) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): BackupCall? { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val callId = cursor.requireLong(CallTable.CALL_ID) + val type = CallTable.Type.deserialize(cursor.requireInt(CallTable.TYPE)) + val direction = CallTable.Direction.deserialize(cursor.requireInt(CallTable.DIRECTION)) + val event = CallTable.Event.deserialize(cursor.requireInt(CallTable.EVENT)) + + return BackupCall( + callId = callId, + conversationRecipientId = cursor.requireLong(CallTable.PEER), + type = when (type) { + CallTable.Type.AUDIO_CALL -> Call.Type.AUDIO_CALL + CallTable.Type.VIDEO_CALL -> Call.Type.VIDEO_CALL + CallTable.Type.AD_HOC_CALL -> Call.Type.AD_HOC_CALL + CallTable.Type.GROUP_CALL -> Call.Type.GROUP_CALL + }, + outgoing = when (direction) { + CallTable.Direction.OUTGOING -> true + else -> false + }, + timestamp = cursor.requireLong(CallTable.TIMESTAMP), + ringerRecipientId = if (cursor.isNull(CallTable.RINGER)) null else cursor.requireLong(CallTable.RINGER), + event = when (event) { + CallTable.Event.ONGOING -> Call.Event.OUTGOING + CallTable.Event.OUTGOING_RING -> Call.Event.OUTGOING_RING + CallTable.Event.ACCEPTED -> Call.Event.ACCEPTED + CallTable.Event.DECLINED -> Call.Event.DECLINED + CallTable.Event.GENERIC_GROUP_CALL -> Call.Event.GENERIC_GROUP_CALL + CallTable.Event.JOINED -> Call.Event.JOINED + CallTable.Event.MISSED -> Call.Event.MISSED + CallTable.Event.DELETE -> Call.Event.DELETE + CallTable.Event.RINGING -> Call.Event.UNKNOWN_EVENT + CallTable.Event.NOT_ACCEPTED -> Call.Event.NOT_ACCEPTED + } + ) + } + + override fun close() { + cursor.close() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt index 795c090a1a..b1c893ccf0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt @@ -6,42 +6,55 @@ package org.thoughtcrime.securesms.backup.v2.database import android.database.Cursor +import com.annimon.stream.Stream import okio.ByteString.Companion.toByteString import org.signal.core.util.Base64 +import org.signal.core.util.Base64.decodeOrThrow import org.signal.core.util.logging.Log import org.signal.core.util.requireBlob import org.signal.core.util.requireBoolean import org.signal.core.util.requireInt import org.signal.core.util.requireLong import org.signal.core.util.requireString +import org.thoughtcrime.securesms.backup.v2.proto.CallChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.Quote import org.thoughtcrime.securesms.backup.v2.proto.Reaction import org.thoughtcrime.securesms.backup.v2.proto.RemoteDeletedMessage import org.thoughtcrime.securesms.backup.v2.proto.SendStatus +import org.thoughtcrime.securesms.backup.v2.proto.SessionSwitchoverChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage import org.thoughtcrime.securesms.backup.v2.proto.Text +import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate import org.thoughtcrime.securesms.database.GroupReceiptTable import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.MessageTypes import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.calls import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet import org.thoughtcrime.securesms.database.documents.NetworkFailureSet +import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails +import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent +import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.util.JsonUtils +import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.api.util.toByteArray import java.io.Closeable import java.io.IOException import java.util.LinkedList import java.util.Queue +import java.util.UUID import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange /** @@ -121,6 +134,79 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } ) } + MessageTypes.isSessionSwitchoverType(record.type) -> { + builder.updateMessage = ChatUpdateMessage( + sessionSwitchover = try { + val event = SessionSwitchoverEvent.ADAPTER.decode(decodeOrThrow(record.body!!)) + SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!) + } catch (e: Exception) { + SessionSwitchoverChatUpdate() + } + ) + } + MessageTypes.isThreadMergeType(record.type) -> { + builder.updateMessage = ChatUpdateMessage( + threadMerge = try { + val event = ThreadMergeEvent.ADAPTER.decode(decodeOrThrow(record.body!!)) + ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!) + } catch (e: Exception) { + ThreadMergeChatUpdate() + } + ) + } + MessageTypes.isCallLog(record.type) -> { + val call = calls.getCallByMessageId(record.id) + if (call != null) { + builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callId = call.callId)) + } else { + when { + MessageTypes.isMissedAudioCall(record.type) -> { + builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL))) + } + MessageTypes.isMissedVideoCall(record.type) -> { + builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL))) + } + MessageTypes.isIncomingAudioCall(record.type) -> { + builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL))) + } + MessageTypes.isIncomingVideoCall(record.type) -> { + builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL))) + } + MessageTypes.isOutgoingAudioCall(record.type) -> { + builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL))) + } + MessageTypes.isOutgoingVideoCall(record.type) -> { + builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL))) + } + MessageTypes.isGroupCall(record.type) -> { + try { + val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body) + + val joinedMembers = Stream.of(groupCallUpdateDetails.inCallUuids) + .map { uuid: String? -> UuidUtil.parseOrNull(uuid) } + .withoutNulls() + .map { obj: UUID? -> ACI.from(obj!!).toByteString() } + .toList() + builder.updateMessage = ChatUpdateMessage( + callingMessage = CallChatUpdate( + groupCall = GroupCallChatUpdate( + startedCallAci = ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid)).toByteString(), + startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp, + inCallAcis = joinedMembers + ) + ) + ) + } catch (exception: java.lang.Exception) { + continue + } + } + } + } + } + record.body == null -> { + Log.w(TAG, "Record missing a body, skipping") + continue + } else -> builder.standardMessage = record.toTextMessage(reactionsById[id]) } @@ -138,6 +224,16 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: cursor.close() } + private fun String.e164ToLong(): Long? { + val fixed = if (this.startsWith("+")) { + this.substring(1) + } else { + this + } + + return fixed.toLongOrNull() + } + private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List?): ChatItem.Builder { val record = this diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index 152f18180f..5c049409c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -10,16 +10,19 @@ import androidx.core.content.contentValuesOf import org.signal.core.util.Base64 import org.signal.core.util.SqlUtil import org.signal.core.util.logging.Log +import org.signal.core.util.requireLong import org.signal.core.util.toInt import org.thoughtcrime.securesms.backup.v2.BackupState import org.thoughtcrime.securesms.backup.v2.proto.BodyRange import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage +import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.Quote import org.thoughtcrime.securesms.backup.v2.proto.Reaction import org.thoughtcrime.securesms.backup.v2.proto.SendStatus import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage +import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.GroupReceiptTable import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.MessageTypes @@ -31,6 +34,8 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure import org.thoughtcrime.securesms.database.documents.NetworkFailureSet import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails +import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent +import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -131,7 +136,7 @@ class ChatItemImportInserter( return } - buffer.messages += chatItem.toMessageContentValues(fromLocalRecipientId, chatLocalRecipientId, localThreadId) + buffer.messages += chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId) buffer.reactions += chatItem.toReactionContentValues(messageId) buffer.groupReceipts += chatItem.toGroupReceiptContentValues(messageId, chatBackupRecipientId) @@ -148,8 +153,18 @@ class ChatItemImportInserter( return false } - SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach { - db.execSQL(it.where, it.whereArgs) + buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach { + db.rawQuery("${it.query.where} RETURNING ${MessageTable.ID}", it.query.whereArgs).use { cursor -> + var index = 0 + while (cursor.moveToNext()) { + val rowId = cursor.requireLong(MessageTable.ID) + val followup = it.inserts[index].followUp + if (followup != null) { + followup(rowId) + } + index++ + } + } } SqlUtil.buildBulkInsert(ReactionTable.TABLE_NAME, REACTION_COLUMNS, buffer.reactions).forEach { @@ -165,6 +180,33 @@ class ChatItemImportInserter( return true } + private fun buildBulkInsert(tableName: String, columns: Array, messageInserts: List, maxQueryArgs: Int = 999): List { + val batchSize = maxQueryArgs / columns.size + + return messageInserts + .chunked(batchSize) + .map { batch: List -> BatchInsert(batch, SqlUtil.buildSingleBulkInsert(tableName, columns, batch.map { it.contentValues })) } + .toList() + } + + private fun ChatItem.toMessageInsert(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): MessageInsert { + val contentValues = this.toMessageContentValues(fromRecipientId, chatRecipientId, threadId) + + var followUp: ((Long) -> Unit)? = null + if (this.updateMessage != null) { + if (this.updateMessage.callingMessage != null && this.updateMessage.callingMessage.callId != null) { + followUp = { messageRowId -> + val callContentValues = ContentValues() + callContentValues.put(CallTable.MESSAGE_ID, messageRowId) + db.update(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, callContentValues, "${CallTable.CALL_ID} = ?", SqlUtil.buildArgs(this.updateMessage.callingMessage.callId)) + } + } + } + return MessageInsert(contentValues, followUp) + } + + private class BatchInsert(val inserts: List, val query: SqlUtil.Query) + private fun ChatItem.toMessageContentValues(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): ContentValues { val contentValues = ContentValues() @@ -338,6 +380,36 @@ class ChatItemImportInserter( .encode() put(MessageTable.BODY, Base64.encodeWithPadding(profileChangeDetails)) } + updateMessage.sessionSwitchover != null -> { + typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE + val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode() + put(MessageTable.BODY, Base64.encodeWithPadding(sessionSwitchoverDetails)) + } + updateMessage.threadMerge != null -> { + typeFlags = MessageTypes.THREAD_MERGE_TYPE + val threadMergeDetails = ThreadMergeEvent(previousE164 = updateMessage.threadMerge.previousE164.toString()).encode() + put(MessageTable.BODY, Base64.encodeWithPadding(threadMergeDetails)) + } + updateMessage.callingMessage != null -> { + when { + updateMessage.callingMessage.callId != null -> { + typeFlags = backupState.callIdToType[updateMessage.callingMessage.callId]!! + } + updateMessage.callingMessage.callMessage != null -> { + typeFlags = when (updateMessage.callingMessage.callMessage.type) { + IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL -> MessageTypes.INCOMING_AUDIO_CALL_TYPE + IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL -> MessageTypes.INCOMING_VIDEO_CALL_TYPE + IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL -> MessageTypes.OUTGOING_AUDIO_CALL_TYPE + IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL -> MessageTypes.OUTGOING_VIDEO_CALL_TYPE + IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL -> MessageTypes.MISSED_AUDIO_CALL_TYPE + IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL -> MessageTypes.MISSED_VIDEO_CALL_TYPE + IndividualCallChatUpdate.Type.UNKNOWN -> typeFlags + } + } + } + // Calls don't use the incoming/outgoing flags, so we overwrite the flags here + this.put(MessageTable.TYPE, typeFlags) + } } this.put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or typeFlags) } @@ -431,8 +503,10 @@ class ChatItemImportInserter( } } + private class MessageInsert(val contentValues: ContentValues, val followUp: ((Long) -> Unit)?) + private class Buffer( - val messages: MutableList = mutableListOf(), + val messages: MutableList = mutableListOf(), val reactions: MutableList = mutableListOf(), val groupReceipts: MutableList = mutableListOf() ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt index bd707276c3..4b90c64955 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt @@ -58,7 +58,7 @@ fun MessageTable.getMessagesForBackup(): ChatItemExportIterator { ${MessageTypes.BASE_SENT_TYPE}, ${MessageTypes.BASE_SENDING_TYPE}, ${MessageTypes.BASE_SENT_FAILED_TYPE} - ) + ) OR ${MessageTable.IS_CALL_TYPE_CLAUSE} """ ) .orderBy("${MessageTable.DATE_RECEIVED} ASC") diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/CallLogBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/CallLogBackupProcessor.kt new file mode 100644 index 0000000000..d17deaec7c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/CallLogBackupProcessor.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.processor + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.database.getCallsForBackup +import org.thoughtcrime.securesms.backup.v2.database.restoreCallLogFromBackup +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter +import org.thoughtcrime.securesms.database.SignalDatabase + +typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call + +object CallLogBackupProcessor { + + val TAG = Log.tag(CallLogBackupProcessor::class.java) + + fun export(emitter: BackupFrameEmitter) { + SignalDatabase.calls.getCallsForBackup().use { reader -> + for (callLog in reader) { + if (callLog != null) { + emitter.emit(Frame(call = callLog)) + } + } + } + } + + fun import(call: BackupCall, backupState: BackupState) { + SignalDatabase.calls.restoreCallLogFromBackup(call, backupState) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index 8b2cdd9777..054276a2db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -49,16 +49,16 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl private val TIME_WINDOW = TimeUnit.HOURS.toMillis(4) const val TABLE_NAME = "call" - private const val ID = "_id" - private const val CALL_ID = "call_id" - private const val MESSAGE_ID = "message_id" + const val ID = "_id" + const val CALL_ID = "call_id" + const val MESSAGE_ID = "message_id" const val PEER = "peer" const val TYPE = "type" - private const val DIRECTION = "direction" + const val DIRECTION = "direction" const val EVENT = "event" const val TIMESTAMP = "timestamp" - private const val RINGER = "ringer" - private const val DELETION_TIMESTAMP = "deletion_timestamp" + const val RINGER = "ringer" + const val DELETION_TIMESTAMP = "deletion_timestamp" //language=sql val CREATE_TABLE = """ 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 d49ca61eaf..cfe5b3b574 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -413,7 +413,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ORDER BY $DATE_RECEIVED DESC LIMIT 1 """ - private val IS_CALL_TYPE_CLAUSE = """( + const val IS_CALL_TYPE_CLAUSE = """( ($TYPE = ${MessageTypes.INCOMING_AUDIO_CALL_TYPE}) OR ($TYPE = ${MessageTypes.INCOMING_VIDEO_CALL_TYPE}) diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index 5596d81855..6bcb277bbf 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -192,7 +192,7 @@ message Call { Type type = 3; bool outgoing = 4; uint64 timestamp = 5; - uint64 ringerRecipientId = 6; + optional uint64 ringerRecipientId = 6; Event event = 7; } @@ -505,6 +505,7 @@ message IndividualCallChatUpdate { MISSED_AUDIO_CALL = 5; MISSED_VIDEO_CALL = 6; } + Type type = 1; } message GroupCallChatUpdate { diff --git a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt index 3af6f8e298..64848811b4 100644 --- a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt +++ b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt @@ -393,7 +393,7 @@ object SqlUtil { .toList() } - private fun buildSingleBulkInsert(tableName: String, columns: Array, contentValues: List): Query { + fun buildSingleBulkInsert(tableName: String, columns: Array, contentValues: List): Query { val builder = StringBuilder() builder.append("INSERT INTO ").append(tableName).append(" (")