diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java index 5f132e1d30..543332020d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -13,6 +13,7 @@ import org.signal.paging.PagedDataSource; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.conversation.ConversationData.MessageRequestData; import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; +import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.MmsSmsTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; @@ -32,6 +33,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; @@ -98,6 +100,7 @@ public class ConversationDataSource implements PagedDataSource referencedIds = new HashSet<>(); try (MmsSmsTable.Reader reader = MmsSmsTable.readerFor(db.getConversation(threadId, start, length))) { @@ -109,6 +112,7 @@ public class ConversationDataSource implements PagedDataSource messageIds = new LinkedList<>(); + private Map messageIdToCall = Collections.emptyMap(); + + public void add(MessageRecord messageRecord) { + if (messageRecord.isCallLog() && !messageRecord.isGroupCall()) { + messageIds.add(messageRecord.getId()); + } + } + + public void fetchCalls() { + if (!messageIds.isEmpty()) { + messageIdToCall = SignalDatabase.calls().getCalls(messageIds); + } + } + + @NonNull List buildUpdatedModels(@NonNull List records) { + return records.stream() + .map(record -> { + if (record.isCallLog() && record instanceof MediaMmsMessageRecord) { + CallTable.Call call = messageIdToCall.get(record.getId()); + if (call != null) { + return ((MediaMmsMessageRecord) record).withCall(call); + } + } + return record; + }) + .collect(Collectors.toList()); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt new file mode 100644 index 0000000000..52048c165e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -0,0 +1,266 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import android.database.Cursor +import androidx.core.content.contentValuesOf +import org.signal.core.util.IntSerializer +import org.signal.core.util.Serializer +import org.signal.core.util.SqlUtil +import org.signal.core.util.logging.Log +import org.signal.core.util.readToList +import org.signal.core.util.readToSingleObject +import org.signal.core.util.requireLong +import org.signal.core.util.requireObject +import org.signal.core.util.select +import org.signal.core.util.update +import org.signal.core.util.withinTransaction +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent + +/** + * Contains details for each 1:1 call. + */ +class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper), RecipientIdDatabaseReference { + + companion object { + private val TAG = Log.tag(CallTable::class.java) + + private const val TABLE_NAME = "call" + private const val ID = "_id" + private const val CALL_ID = "call_id" + private const val MESSAGE_ID = "message_id" + private const val PEER = "peer" + private const val TYPE = "type" + private const val DIRECTION = "direction" + private const val EVENT = "event" + + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $CALL_ID INTEGER NOT NULL UNIQUE, + $MESSAGE_ID INTEGER NOT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE CASCADE, + $PEER INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, + $TYPE INTEGER NOT NULL, + $DIRECTION INTEGER NOT NULL, + $EVENT INTEGER NOT NULL + ) + """.trimIndent() + + val CREATE_INDEXES = arrayOf( + "CREATE INDEX call_call_id_index ON $TABLE_NAME ($CALL_ID)", + "CREATE INDEX call_message_id_index ON $TABLE_NAME ($MESSAGE_ID)" + ) + } + + fun insertCall(callId: Long, timestamp: Long, peer: RecipientId, type: Type, direction: Direction, event: Event) { + val messageType: Long = Call.getMessageType(type, direction, event) + + writableDatabase.withinTransaction { + val result = SignalDatabase.messages.insertCallLog(peer, messageType, timestamp) + + val values = contentValuesOf( + CALL_ID to callId, + MESSAGE_ID to result.messageId, + PEER to peer.serialize(), + TYPE to Type.serialize(type), + DIRECTION to Direction.serialize(direction), + EVENT to Event.serialize(event) + ) + + writableDatabase.insert(TABLE_NAME, null, values) + } + + ApplicationDependencies.getMessageNotifier().updateNotification(context) + + Log.i(TAG, "Inserted call: $callId type: $type direction: $direction event:$event") + } + + fun updateCall(callId: Long, event: Event): Call? { + return writableDatabase.withinTransaction { + writableDatabase + .update(TABLE_NAME) + .values(EVENT to Event.serialize(event)) + .where("$CALL_ID = ?", callId) + .run() + + val call = readableDatabase + .select() + .from(TABLE_NAME) + .where("$CALL_ID = ?", callId) + .run() + .readToSingleObject(Call.Deserializer) + + if (call != null) { + Log.i(TAG, "Updated call: $callId event: $event") + + SignalDatabase.messages.updateCallLog(call.messageId, call.messageType) + ApplicationDependencies.getMessageNotifier().updateNotification(context) + } + + call + } + } + + fun getCallById(callId: Long): Call? { + return readableDatabase + .select() + .from(TABLE_NAME) + .where("$CALL_ID = ?", callId) + .run() + .readToSingleObject(Call.Deserializer) + } + + fun getCallByMessageId(messageId: Long): Call? { + return readableDatabase + .select() + .from(TABLE_NAME) + .where("$MESSAGE_ID = ?", messageId) + .run() + .readToSingleObject(Call.Deserializer) + } + + fun getCalls(messageIds: Collection): Map { + val calls = mutableMapOf() + val queries = SqlUtil.buildCollectionQuery(MESSAGE_ID, messageIds) + + queries.forEach { query -> + val cursor = readableDatabase + .select() + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + + calls.putAll(cursor.readToList { c -> c.requireLong(MESSAGE_ID) to Call.deserialize(c) }) + } + return calls + } + + override fun remapRecipient(fromId: RecipientId, toId: RecipientId) { + writableDatabase + .update(TABLE_NAME) + .values(PEER to toId.serialize()) + .where("$PEER = ?", fromId) + .run() + } + + data class Call( + val callId: Long, + val peer: RecipientId, + val type: Type, + val direction: Direction, + val event: Event, + val messageId: Long + ) { + val messageType: Long = getMessageType(type, direction, event) + + companion object Deserializer : Serializer { + fun getMessageType(type: Type, direction: Direction, event: Event): Long { + return if (direction == Direction.INCOMING && event == Event.MISSED) { + if (type == Type.VIDEO_CALL) MmsSmsColumns.Types.MISSED_VIDEO_CALL_TYPE else MmsSmsColumns.Types.MISSED_AUDIO_CALL_TYPE + } else if (direction == Direction.INCOMING) { + if (type == Type.VIDEO_CALL) MmsSmsColumns.Types.INCOMING_VIDEO_CALL_TYPE else MmsSmsColumns.Types.INCOMING_AUDIO_CALL_TYPE + } else { + if (type == Type.VIDEO_CALL) MmsSmsColumns.Types.OUTGOING_VIDEO_CALL_TYPE else MmsSmsColumns.Types.OUTGOING_AUDIO_CALL_TYPE + } + } + + override fun serialize(data: Call): Cursor { + throw UnsupportedOperationException() + } + + override fun deserialize(data: Cursor): Call { + return Call( + callId = data.requireLong(CALL_ID), + peer = RecipientId.from(data.requireLong(PEER)), + type = data.requireObject(TYPE, Type.Serializer), + direction = data.requireObject(DIRECTION, Direction.Serializer), + event = data.requireObject(EVENT, Event.Serializer), + messageId = data.requireLong(MESSAGE_ID) + ) + } + } + } + + enum class Type(private val code: Int) { + AUDIO_CALL(0), + VIDEO_CALL(1); + + companion object Serializer : IntSerializer { + override fun serialize(data: Type): Int = data.code + + override fun deserialize(data: Int): Type { + return when (data) { + AUDIO_CALL.code -> AUDIO_CALL + VIDEO_CALL.code -> VIDEO_CALL + else -> throw IllegalArgumentException("Unknown type $data") + } + } + + @JvmStatic + fun from(type: CallEvent.Type): Type? { + return when (type) { + CallEvent.Type.UNKNOWN_TYPE -> null + CallEvent.Type.AUDIO_CALL -> AUDIO_CALL + CallEvent.Type.VIDEO_CALL -> VIDEO_CALL + } + } + } + } + + enum class Direction(private val code: Int) { + INCOMING(0), + OUTGOING(1); + + companion object Serializer : IntSerializer { + override fun serialize(data: Direction): Int = data.code + + override fun deserialize(data: Int): Direction { + return when (data) { + INCOMING.code -> INCOMING + OUTGOING.code -> OUTGOING + else -> throw IllegalArgumentException("Unknown type $data") + } + } + + @JvmStatic + fun from(direction: CallEvent.Direction): Direction? { + return when (direction) { + CallEvent.Direction.UNKNOWN_DIRECTION -> null + CallEvent.Direction.INCOMING -> INCOMING + CallEvent.Direction.OUTGOING -> OUTGOING + } + } + } + } + + enum class Event(private val code: Int) { + ONGOING(0), + ACCEPTED(1), + NOT_ACCEPTED(2), + MISSED(3); + + companion object Serializer : IntSerializer { + override fun serialize(data: Event): Int = data.code + + override fun deserialize(data: Int): Event { + return when (data) { + ONGOING.code -> ONGOING + ACCEPTED.code -> ACCEPTED + NOT_ACCEPTED.code -> NOT_ACCEPTED + MISSED.code -> MISSED + else -> throw IllegalArgumentException("Unknown type $data") + } + } + + @JvmStatic + fun from(event: CallEvent.Event): Event? { + return when (event) { + CallEvent.Event.UNKNOWN_ACTION -> null + CallEvent.Event.ACCEPTED -> ACCEPTED + CallEvent.Event.NOT_ACCEPTED -> NOT_ACCEPTED + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java index e1835b2aba..acffd6b227 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java @@ -559,23 +559,12 @@ public class MessageTable extends DatabaseTable implements MmsSmsColumns, Recipi return results; } - public @NonNull Pair insertReceivedCall(@NonNull RecipientId address, boolean isVideoOffer) { - return insertCallLog(address, isVideoOffer ? Types.INCOMING_VIDEO_CALL_TYPE : Types.INCOMING_AUDIO_CALL_TYPE, false, System.currentTimeMillis()); - } - - public @NonNull Pair insertOutgoingCall(@NonNull RecipientId address, boolean isVideoOffer) { - return insertCallLog(address, isVideoOffer ? Types.OUTGOING_VIDEO_CALL_TYPE : Types.OUTGOING_AUDIO_CALL_TYPE, false, System.currentTimeMillis()); - } - - public @NonNull Pair insertMissedCall(@NonNull RecipientId address, long timestamp, boolean isVideoOffer) { - return insertCallLog(address, isVideoOffer ? Types.MISSED_VIDEO_CALL_TYPE : Types.MISSED_AUDIO_CALL_TYPE, true, timestamp); - } - - private @NonNull Pair insertCallLog(@NonNull RecipientId recipientId, long type, boolean unread, long timestamp) { + public @NonNull InsertResult insertCallLog(@NonNull RecipientId recipientId, long type, long timestamp) { + boolean unread = Types.isMissedAudioCall(type) || Types.isMissedVideoCall(type); Recipient recipient = Recipient.resolved(recipientId); long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); - ContentValues values = new ContentValues(6); + ContentValues values = new ContentValues(7); values.put(RECIPIENT_ID, recipientId.serialize()); values.put(RECIPIENT_DEVICE_ID, 1); values.put(DATE_RECEIVED, System.currentTimeMillis()); @@ -584,19 +573,40 @@ public class MessageTable extends DatabaseTable implements MmsSmsColumns, Recipi values.put(TYPE, type); values.put(THREAD_ID, threadId); - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - long messageId = db.insert(TABLE_NAME, null, values); + long messageId = getWritableDatabase().insert(TABLE_NAME, null, values); + boolean keepThreadArchived = SignalStore.settings().shouldKeepMutedChatsArchived() && Recipient.resolved(recipientId).isMuted(); if (unread) { SignalDatabase.threads().incrementUnread(threadId, 1, 0); } - boolean keepThreadArchived = SignalStore.settings().shouldKeepMutedChatsArchived() && Recipient.resolved(recipientId).isMuted(); + SignalDatabase.threads().update(threadId, !keepThreadArchived); notifyConversationListeners(threadId); TrimThreadJob.enqueueAsync(threadId); - return new Pair<>(messageId, threadId); + return new InsertResult(messageId, threadId); + } + + public void updateCallLog(long messageId, long type) { + boolean unread = Types.isMissedAudioCall(type) || Types.isMissedVideoCall(type); + ContentValues values = new ContentValues(2); + values.put(TYPE, type); + values.put(READ, unread ? 0 : 1); + getWritableDatabase().update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(messageId)); + + long threadId = getThreadIdForMessage(messageId); + Recipient recipient = SignalDatabase.threads().getRecipientForThreadId(threadId); + boolean keepThreadArchived = SignalStore.settings().shouldKeepMutedChatsArchived() && recipient != null && recipient.isMuted(); + + if (unread) { + SignalDatabase.threads().incrementUnread(threadId, 1, 0); + } + + SignalDatabase.threads().update(threadId, !keepThreadArchived); + + notifyConversationListeners(threadId); + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); } public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, @@ -4179,6 +4189,7 @@ public class MessageTable extends DatabaseTable implements MmsSmsColumns, Recipi message.getStoryType(), message.getParentStoryId(), message.getGiftBadge(), + null, null); } } @@ -4367,7 +4378,7 @@ public class MessageTable extends DatabaseTable implements MmsSmsColumns, Recipi networkFailures, subscriptionId, expiresIn, expireStarted, isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(), remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges, - storyType, parentStoryId, giftBadge, null); + storyType, parentStoryId, giftBadge, null, null); } private Set getMismatchedIdentities(String document) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 4196af611a..d7136bfc13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -74,6 +74,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val cdsTable: CdsTable = CdsTable(context, this) val remoteMegaphoneTable: RemoteMegaphoneTable = RemoteMegaphoneTable(context, this) val pendingPniSignatureMessageTable: PendingPniSignatureMessageTable = PendingPniSignatureMessageTable(context, this) + val callTable: CallTable = CallTable(context, this) override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) { db.setForeignKeyConstraintsEnabled(true) @@ -109,6 +110,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data db.execSQL(CdsTable.CREATE_TABLE) db.execSQL(RemoteMegaphoneTable.CREATE_TABLE) db.execSQL(PendingPniSignatureMessageTable.CREATE_TABLE) + db.execSQL(CallTable.CREATE_TABLE) executeStatements(db, SearchTable.CREATE_TABLE) executeStatements(db, RemappedRecordTables.CREATE_TABLE) executeStatements(db, MessageSendLogTables.CREATE_TABLE) @@ -133,6 +135,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data executeStatements(db, StorySendTable.CREATE_INDEXS) executeStatements(db, DistributionListTables.CREATE_INDEXES) executeStatements(db, PendingPniSignatureMessageTable.CREATE_INDEXES) + executeStatements(db, CallTable.CREATE_INDEXES) executeStatements(db, SearchTable.CREATE_TRIGGERS) executeStatements(db, MessageSendLogTables.CREATE_TRIGGERS) @@ -523,5 +526,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data @get:JvmName("remoteMegaphones") val remoteMegaphones: RemoteMegaphoneTable get() = instance!!.remoteMegaphoneTable + + @get:JvmStatic + @get:JvmName("calls") + val calls: CallTable + get() = instance!!.callTable } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index dc140ea2f6..7b097448b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V166_ThreadAndMessa import org.thoughtcrime.securesms.database.helpers.migration.V167_RecreateReactionTriggers import org.thoughtcrime.securesms.database.helpers.migration.V168_SingleMessageTableMigration import org.thoughtcrime.securesms.database.helpers.migration.V169_EmojiSearchIndexRank +import org.thoughtcrime.securesms.database.helpers.migration.V170_CallTableMigration /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -33,7 +34,7 @@ object SignalDatabaseMigrations { val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass) - const val DATABASE_VERSION = 169 + const val DATABASE_VERSION = 170 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -120,6 +121,10 @@ object SignalDatabaseMigrations { if (oldVersion < 169) { V169_EmojiSearchIndexRank.migrate(context, db, oldVersion, newVersion) } + + if (oldVersion < 170) { + V170_CallTableMigration.migrate(context, db, oldVersion, newVersion) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V170_CallTableMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V170_CallTableMigration.kt new file mode 100644 index 0000000000..bf3d44e961 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V170_CallTableMigration.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +@Suppress("ClassName") +object V170_CallTableMigration : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL( + """ + CREATE TABLE call ( + _id INTEGER PRIMARY KEY, + call_id INTEGER NOT NULL UNIQUE, + message_id INTEGER NOT NULL REFERENCES mms (_id) ON DELETE CASCADE, + peer INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, + type INTEGER NOT NULL, + direction INTEGER NOT NULL, + event INTEGER NOT NULL + ) + """.trimIndent() + ) + + db.execSQL("CREATE INDEX call_call_id_index ON call (call_id)") + db.execSQL("CREATE INDEX call_message_id_index ON call (message_id)") + } +} 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 bc4ca4a39d..8e28fbae65 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 @@ -22,6 +22,7 @@ import android.text.SpannableString; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import androidx.core.content.ContextCompat; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -29,6 +30,7 @@ import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.AttachmentId; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.MmsSmsColumns.Status; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; @@ -39,6 +41,7 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.payments.Payment; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.payments.FormatterOptions; import java.util.HashMap; @@ -47,6 +50,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; import java.util.stream.Collectors; /** @@ -60,9 +64,10 @@ import java.util.stream.Collectors; public class MediaMmsMessageRecord extends MmsMessageRecord { private final static String TAG = Log.tag(MediaMmsMessageRecord.class); - private final boolean mentionsSelf; - private final BodyRangeList messageRanges; - private final Payment payment; + private final boolean mentionsSelf; + private final BodyRangeList messageRanges; + private final Payment payment; + private final CallTable.Call call; public MediaMmsMessageRecord(long id, Recipient conversationRecipient, @@ -97,7 +102,8 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { @NonNull StoryType storyType, @Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge, - @Nullable Payment payment) + @Nullable Payment payment, + @Nullable CallTable.Call call) { super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, @@ -107,6 +113,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { this.mentionsSelf = mentionsSelf; this.messageRanges = messageRanges; this.payment = payment; + this.call = call; } @Override @@ -137,6 +144,40 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return super.getDisplayBody(context); } + @Override + public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context, @Nullable Consumer recipientClickHandler) { + if (isCallLog() && call != null) { + boolean accepted = call.getEvent() == CallTable.Event.ACCEPTED; + String callDateString = getCallDateString(context); + + if (call.getDirection() == CallTable.Direction.OUTGOING) { + if (call.getType() == CallTable.Type.AUDIO_CALL) { + int updateString = accepted ? R.string.MessageRecord_you_called_date : R.string.MessageRecord_unanswered_audio_call_date; + return staticUpdateDescription(context.getString(updateString, callDateString), R.drawable.ic_update_audio_call_outgoing_16); + } else { + int updateString = accepted ? R.string.MessageRecord_you_called_date : R.string.MessageRecord_unanswered_video_call_date; + return staticUpdateDescription(context.getString(updateString, callDateString), R.drawable.ic_update_video_call_outgoing_16); + } + } else { + boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL; + boolean isMissed = call.getEvent() == CallTable.Event.MISSED; + + if (accepted) { + int icon = isVideoCall ? R.drawable.ic_update_video_call_incoming_16 : R.drawable.ic_update_audio_call_incoming_16; + return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_called_you_date, r.getDisplayName(context), callDateString), icon); + } else if (isMissed) { + return isVideoCall ? staticUpdateDescription(context.getString(R.string.MessageRecord_missed_video_call_date, callDateString), R.drawable.ic_update_video_call_missed_16, ContextCompat.getColor(context, R.color.core_red_shade), ContextCompat.getColor(context, R.color.core_red)) + : staticUpdateDescription(context.getString(R.string.MessageRecord_missed_audio_call_date, callDateString), R.drawable.ic_update_audio_call_missed_16, ContextCompat.getColor(context, R.color.core_red_shade), ContextCompat.getColor(context, R.color.core_red)); + } else { + return isVideoCall ? fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_declined_video_call_date, callDateString), R.drawable.ic_update_video_call_incoming_16) + : fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_declined_audio_call_date, callDateString), R.drawable.ic_update_audio_call_incoming_16); + } + } + } + return super.getUpdateDisplayBody(context, recipientClickHandler); + } + + public @Nullable BodyRangeList getMessageRanges() { return messageRanges; } @@ -155,18 +196,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return payment; } + public @Nullable CallTable.Call getCall() { + return call; + } + public @NonNull MediaMmsMessageRecord withReactions(@NonNull List reactions) { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment()); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall()); } public @NonNull MediaMmsMessageRecord withoutQuote() { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment()); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall()); } public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List attachments) { @@ -187,14 +232,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck, getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment()); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall()); } public @NonNull MediaMmsMessageRecord withPayment(@NonNull Payment payment) { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall()); + } + + + public @NonNull MediaMmsMessageRecord withCall(@Nullable CallTable.Call call) { + return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), + getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), + getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call); } private static @NonNull List updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) { 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 3ad5a260af..293abd1d26 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 @@ -337,29 +337,29 @@ public abstract class MessageRecord extends DisplayRecord { return null; } - private @NonNull String getCallDateString(@NonNull Context context) { + protected @NonNull String getCallDateString(@NonNull Context context) { return DateUtils.getSimpleRelativeTimeSpanString(context, Locale.getDefault(), getDateSent()); } - private static @NonNull UpdateDescription fromRecipient(@NonNull Recipient recipient, - @NonNull Function stringGenerator, - @DrawableRes int iconResource) + protected static @NonNull UpdateDescription fromRecipient(@NonNull Recipient recipient, + @NonNull Function stringGenerator, + @DrawableRes int iconResource) { return UpdateDescription.mentioning(Collections.singletonList(recipient.getServiceId().orElse(ServiceId.UNKNOWN)), () -> new SpannableString(stringGenerator.apply(recipient.resolve())), iconResource); } - private static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string, - @DrawableRes int iconResource) + protected static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string, + @DrawableRes int iconResource) { return UpdateDescription.staticDescription(string, iconResource); } - private static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string, - @DrawableRes int iconResource, - @ColorInt int lightTint, - @ColorInt int darkTint) + protected static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string, + @DrawableRes int iconResource, + @ColorInt int lightTint, + @ColorInt int darkTint) { return UpdateDescription.staticDescription(string, iconResource, lightTint, darkTint); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 4440ef8bb4..d594b7e3a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.contactshare.ContactModelMapper; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.SecurityEvent; import org.thoughtcrime.securesms.database.AttachmentTable; +import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; import org.thoughtcrime.securesms.database.GroupReceiptTable; @@ -169,6 +170,7 @@ import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage; import java.io.IOException; import java.security.SecureRandom; @@ -350,6 +352,7 @@ public final class MessageContentProcessor { else if (syncMessage.getOutgoingPaymentMessage().isPresent()) handleSynchronizeOutgoingPayment(content, syncMessage.getOutgoingPaymentMessage().get()); else if (syncMessage.getKeys().isPresent()) handleSynchronizeKeys(syncMessage.getKeys().get(), content.getTimestamp()); else if (syncMessage.getContacts().isPresent()) handleSynchronizeContacts(syncMessage.getContacts().get(), content.getTimestamp()); + else if (syncMessage.getCallEvent().isPresent()) handleSynchronizeCallEvent(syncMessage.getCallEvent().get(), content.getTimestamp()); else warn(String.valueOf(content.getTimestamp()), "Contains no known sync types..."); } else if (content.getCallMessage().isPresent()) { log(String.valueOf(content.getTimestamp()), "Got call message..."); @@ -627,7 +630,7 @@ public final class MessageContentProcessor { @NonNull AnswerMessage message, @NonNull Recipient senderRecipient) { - log(String.valueOf(content), "handleCallAnswerMessage..."); + log(content.getTimestamp(), "handleCallAnswerMessage..."); RemotePeer remotePeer = new RemotePeer(senderRecipient.getId(), new CallId(message.getId())); byte[] remoteIdentityKey = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(senderRecipient.getId()).map(record -> record.getIdentityKey().serialize()).get(); @@ -641,7 +644,7 @@ public final class MessageContentProcessor { @NonNull List messages, @NonNull Recipient senderRecipient) { - log(String.valueOf(content), "handleCallIceUpdateMessage... " + messages.size()); + log(content.getTimestamp(), "handleCallIceUpdateMessage... " + messages.size()); List iceCandidates = new ArrayList<>(messages.size()); long callId = -1; @@ -663,7 +666,7 @@ public final class MessageContentProcessor { @NonNull Optional smsMessageId, @NonNull Recipient senderRecipient) { - log(String.valueOf(content), "handleCallHangupMessage"); + log(content.getTimestamp(), "handleCallHangupMessage"); if (smsMessageId.isPresent()) { SignalDatabase.messages().markAsMissedCall(smsMessageId.get(), false); } else { @@ -1228,6 +1231,45 @@ public final class MessageContentProcessor { ApplicationDependencies.getJobManager().add(new MultiDeviceContactSyncJob(contactsAttachment)); } + private void handleSynchronizeCallEvent(@NonNull SyncMessage.CallEvent callEvent, long envelopeTimestamp) { + if (!callEvent.hasId()) { + log(envelopeTimestamp, "Synchronize call event missing call id, ignoring."); + return; + } + + long callId = callEvent.getId(); + long timestamp = callEvent.getTimestamp(); + CallTable.Type type = CallTable.Type.from(callEvent.getType()); + CallTable.Direction direction = CallTable.Direction.from(callEvent.getDirection()); + CallTable.Event event = CallTable.Event.from(callEvent.getEvent()); + + if (timestamp == 0 || type == null || direction == null || event == null || !callEvent.hasPeerUuid()) { + warn(envelopeTimestamp, "Call event sync message is not valid, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasPeerUuid()); + return; + } + + ServiceId serviceId = ServiceId.fromByteString(callEvent.getPeerUuid()); + RecipientId recipientId = RecipientId.from(serviceId); + + log(envelopeTimestamp, "Synchronize call event call: " + callId); + + CallTable.Call call = SignalDatabase.calls().getCallById(callId); + if (call != null) { + boolean typeMismatch = call.getType() != type; + boolean directionMismatch = call.getDirection() != direction; + boolean eventDowngrade = call.getEvent() == CallTable.Event.ACCEPTED && event != CallTable.Event.ACCEPTED; + boolean peerMismatch = !call.getPeer().equals(recipientId); + + if (typeMismatch || directionMismatch || eventDowngrade || peerMismatch) { + warn(envelopeTimestamp, "Call event sync message is not valid for existing call record, ignoring. type: " + type + " direction: " + direction + " event: " + event + " peerMismatch: " + peerMismatch); + } else { + SignalDatabase.calls().updateCall(callId, event); + } + } else { + SignalDatabase.calls().insertCall(callId, timestamp, recipientId, type, direction, event); + } + } + private void handleSynchronizeSentMessage(@NonNull SignalServiceContent content, @NonNull SentTranscriptMessage message, @NonNull Recipient senderRecipient) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt new file mode 100644 index 0000000000..a0e96a90fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.service.webrtc + +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.ringrtc.RemotePeer +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent + +/** + * Helper for creating call event sync messages. + */ +object CallEventSyncMessageUtil { + @JvmStatic + fun createAcceptedSyncMessage(remotePeer: RemotePeer, timestamp: Long, isOutgoing: Boolean, isVideoCall: Boolean): CallEvent { + return CallEvent + .newBuilder() + .setPeerUuid(Recipient.resolved(remotePeer.id).requireServiceId().toByteString()) + .setId(remotePeer.callId.longValue()) + .setTimestamp(timestamp) + .setType(if (isVideoCall) CallEvent.Type.VIDEO_CALL else CallEvent.Type.AUDIO_CALL) + .setDirection(if (isOutgoing) CallEvent.Direction.OUTGOING else CallEvent.Direction.INCOMING) + .setEvent(CallEvent.Event.ACCEPTED) + .build() + } + + @JvmStatic + fun createNotAcceptedSyncMessage(remotePeer: RemotePeer, timestamp: Long, isOutgoing: Boolean, isVideoCall: Boolean): CallEvent { + return CallEvent + .newBuilder() + .setPeerUuid(Recipient.resolved(remotePeer.id).requireServiceId().toByteString()) + .setId(remotePeer.callId.longValue()) + .setTimestamp(timestamp) + .setType(if (isVideoCall) CallEvent.Type.VIDEO_CALL else CallEvent.Type.AUDIO_CALL) + .setDirection(if (isOutgoing) CallEvent.Direction.OUTGOING else CallEvent.Direction.INCOMING) + .setEvent(CallEvent.Event.NOT_ACCEPTED) + .build() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java index 85219d7df6..3c0a9319e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallSetupActionProcessorDelegate.java @@ -37,6 +37,12 @@ public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor { RemotePeer activePeer = currentState.getCallInfoState().requireActivePeer(); + webRtcInteractor.sendAcceptedCallEventSyncMessage( + activePeer, + currentState.getCallInfoState().getCallState() == WebRtcViewModel.State.CALL_RINGING, + currentState.getCallSetupState(activePeer).isAcceptWithVideo() || currentState.getLocalDeviceState().getCameraState().isEnabled() + ); + ApplicationDependencies.getAppForegroundObserver().removeListener(webRtcInteractor.getForegroundListener()); webRtcInteractor.startAudioCommunication(); webRtcInteractor.activateCall(activePeer.getId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java index 68a0a78233..4be1497471 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java @@ -10,6 +10,7 @@ import org.signal.core.util.logging.Log; import org.signal.ringrtc.CallException; import org.signal.ringrtc.CallId; import org.signal.ringrtc.CallManager; +import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -130,8 +131,6 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { Log.i(TAG, "handleAcceptCall(): call_id: " + activePeer.getCallId()); - SignalDatabase.messages().insertReceivedCall(activePeer.getId(), currentState.getCallSetupState(activePeer).isRemoteVideoOffer()); - currentState = currentState.builder() .changeCallSetupState(activePeer.getCallId()) .acceptWithVideo(answerWithVideo) @@ -156,10 +155,13 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { Log.i(TAG, "handleDenyCall():"); + webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer, + false, + currentState.getCallSetupState(activePeer).isAcceptWithVideo() || currentState.getLocalDeviceState().getCameraState().isEnabled()); + try { webRtcInteractor.rejectIncomingCall(activePeer.getId()); webRtcInteractor.getCallManager().hangup(); - SignalDatabase.messages().insertMissedCall(activePeer.getId(), System.currentTimeMillis(), currentState.getCallSetupState(activePeer).isRemoteVideoOffer()); return terminate(currentState, activePeer); } catch (CallException e) { return callFailure(currentState, "hangup() failed: ", e); @@ -173,6 +175,14 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { Recipient recipient = remotePeer.getRecipient(); activePeer.localRinging(); + + SignalDatabase.calls().insertCall(remotePeer.getCallId().longValue(), + System.currentTimeMillis(), + remotePeer.getId(), + currentState.getCallSetupState(activePeer).isRemoteVideoOffer() ? CallTable.Type.VIDEO_CALL : CallTable.Type.AUDIO_CALL, + CallTable.Direction.INCOMING, + CallTable.Event.ONGOING); + webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE); boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java index 5018631cf7..8fae7790c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java @@ -11,6 +11,7 @@ import org.signal.ringrtc.CallException; import org.signal.ringrtc.CallId; import org.signal.ringrtc.CallManager; import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper; +import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; @@ -83,7 +84,13 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { } RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, Recipient.resolved(remotePeer.getId()), SignalDatabase.threads().getThreadIdIfExistsFor(remotePeer.getId())); - SignalDatabase.messages().insertOutgoingCall(remotePeer.getId(), isVideoCall); + + SignalDatabase.calls().insertCall(remotePeer.getCallId().longValue(), + System.currentTimeMillis(), + remotePeer.getId(), + isVideoCall ? CallTable.Type.VIDEO_CALL : CallTable.Type.AUDIO_CALL, + CallTable.Direction.OUTGOING, + CallTable.Event.ONGOING); EglBaseWrapper.replaceHolder(EglBaseWrapper.OUTGOING_PLACEHOLDER, remotePeer.getCallId().longValue()); @@ -236,6 +243,13 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { @Override protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) { + RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); + if (activePeer != null) { + webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer, + true, + currentState.getCallSetupState(activePeer).isAcceptWithVideo() || currentState.getLocalDeviceState().getCameraState().isEnabled()); + } + return activeCallDelegate.handleLocalHangup(currentState); } @@ -246,6 +260,19 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { @Override protected @NonNull WebRtcServiceState handleEndedRemote(@NonNull WebRtcServiceState currentState, @NonNull CallManager.CallEvent endedRemoteEvent, @NonNull RemotePeer remotePeer) { + RemotePeer activePeer = currentState.getCallInfoState().getActivePeer(); + if (activePeer != null && + (endedRemoteEvent == CallManager.CallEvent.ENDED_REMOTE_HANGUP || + endedRemoteEvent == CallManager.CallEvent.ENDED_REMOTE_HANGUP_NEED_PERMISSION || + endedRemoteEvent == CallManager.CallEvent.ENDED_REMOTE_BUSY || + endedRemoteEvent == CallManager.CallEvent.ENDED_TIMEOUT || + endedRemoteEvent == CallManager.CallEvent.ENDED_REMOTE_GLARE)) + { + webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer, + true, + currentState.getCallSetupState(activePeer).isAcceptWithVideo() || currentState.getLocalDeviceState().getCameraState().isEnabled()); + } + return activeCallDelegate.handleEndedRemote(currentState, endedRemoteEvent, remotePeer); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index 825d56fbc6..d9a970105a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -31,6 +31,7 @@ import org.signal.storageservice.protos.groups.GroupExternalCredential; import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.GroupTable; +import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.GroupCallPeekEvent; @@ -66,8 +67,10 @@ import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.OpaqueMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage; import java.io.IOException; import java.util.Collection; @@ -521,7 +524,7 @@ private void processStateless(@NonNull Function1 messageAndThreadId = SignalDatabase.messages().insertMissedCall(remotePeer.getId(), timestamp, isVideoOffer); + public void insertMissedCall(@NonNull RemotePeer remotePeer, long timestamp, boolean isVideoOffer) { + CallTable.Call call = SignalDatabase.calls() + .updateCall(remotePeer.getCallId().longValue(), CallTable.Event.MISSED); - ApplicationDependencies.getMessageNotifier() - .updateNotification(context, ConversationId.forConversation(messageAndThreadId.second()), signal); + if (call == null) { + CallTable.Type type = isVideoOffer ? CallTable.Type.VIDEO_CALL : CallTable.Type.AUDIO_CALL; + + SignalDatabase.calls() + .insertCall(remotePeer.getCallId().longValue(), timestamp, remotePeer.getId(), type, CallTable.Direction.INCOMING, CallTable.Event.MISSED); + } } - public void insertReceivedCall(@NonNull RemotePeer remotePeer, boolean signal, boolean isVideoOffer) { - Pair messageAndThreadId = SignalDatabase.messages().insertReceivedCall(remotePeer.getId(), isVideoOffer); + public void insertReceivedCall(@NonNull RemotePeer remotePeer, boolean isVideoOffer) { + CallTable.Call call = SignalDatabase.calls() + .updateCall(remotePeer.getCallId().longValue(), CallTable.Event.ACCEPTED); - ApplicationDependencies.getMessageNotifier() - .updateNotification(context, ConversationId.forConversation(messageAndThreadId.second()), signal); + if (call == null) { + CallTable.Type type = isVideoOffer ? CallTable.Type.VIDEO_CALL : CallTable.Type.AUDIO_CALL; + + SignalDatabase.calls() + .insertCall(remotePeer.getCallId().longValue(), System.currentTimeMillis(), remotePeer.getId(), type, CallTable.Direction.INCOMING, CallTable.Event.ACCEPTED); + } } public void retrieveTurnServers(@NonNull RemotePeer remotePeer) { @@ -919,6 +932,40 @@ private void processStateless(@NonNull Function1 { + try { + SyncMessage.CallEvent callEvent = CallEventSyncMessageUtil.createAcceptedSyncMessage(remotePeer, System.currentTimeMillis(), isOutgoing, isVideoCall); + ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(SignalServiceSyncMessage.forCallEvent(callEvent), Optional.empty()); + } catch (IOException | UntrustedIdentityException e) { + Log.w(TAG, "Unable to send call event sync message for " + remotePeer.getCallId().longValue(), e); + } + }); + } + } + + public void sendNotAcceptedCallEventSyncMessage(@NonNull RemotePeer remotePeer, boolean isOutgoing, boolean isVideoCall) { + SignalDatabase + .calls() + .updateCall(remotePeer.getCallId().longValue(), CallTable.Event.NOT_ACCEPTED); + + if (TextSecurePreferences.isMultiDevice(context)) { + networkExecutor.execute(() -> { + try { + SyncMessage.CallEvent callEvent = CallEventSyncMessageUtil.createNotAcceptedSyncMessage(remotePeer, System.currentTimeMillis(), isOutgoing, isVideoCall); + ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(SignalServiceSyncMessage.forCallEvent(callEvent), Optional.empty()); + } catch (IOException | UntrustedIdentityException e) { + Log.w(TAG, "Unable to send call event sync message for " + remotePeer.getCallId().longValue(), e); + } + }); + } + } + private void processSendMessageFailureWithChangeDetection(@NonNull RemotePeer remotePeer, @NonNull ProcessAction failureProcessAction) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java index 8f4cb81c80..5f5b9ce836 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java @@ -109,11 +109,11 @@ public class WebRtcInteractor { } void insertMissedCall(@NonNull RemotePeer remotePeer, long timestamp, boolean isVideoOffer) { - signalCallManager.insertMissedCall(remotePeer, true, timestamp, isVideoOffer); + signalCallManager.insertMissedCall(remotePeer, timestamp, isVideoOffer); } void insertReceivedCall(@NonNull RemotePeer remotePeer, boolean isVideoOffer) { - signalCallManager.insertReceivedCall(remotePeer, true, isVideoOffer); + signalCallManager.insertReceivedCall(remotePeer, isVideoOffer); } boolean startWebRtcCallActivityIfPossible() { @@ -187,4 +187,12 @@ public class WebRtcInteractor { public void requestGroupMembershipProof(GroupId.V2 groupId, int groupCallHashCode) { signalCallManager.requestGroupMembershipToken(groupId, groupCallHashCode); } + + public void sendAcceptedCallEventSyncMessage(@NonNull RemotePeer remotePeer, boolean isOutgoing, boolean isVideoCall) { + signalCallManager.sendAcceptedCallEventSyncMessage(remotePeer, isOutgoing, isVideoCall); + } + + public void sendNotAcceptedCallEventSyncMessage(@NonNull RemotePeer remotePeer, boolean isOutgoing, boolean isVideoCall) { + signalCallManager.sendNotAcceptedCallEventSyncMessage(remotePeer, isOutgoing, isVideoCall); + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49bb3424f1..6d32e81a42 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1153,8 +1153,16 @@ You updated the group. The group was updated. You called · %1$s + + Unanswered audio call · %1$s + + Unanswered video call · %1$s Missed audio call · %1$s Missed video call · %1$s + + Declined audio call · %1$s + + Declined video call · %1$s %s updated the group. %1$s called you · %2$s %s is on Signal! diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 73a5332171..7765aa3691 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -137,7 +137,8 @@ object FakeMessageRecords { storyType: StoryType = StoryType.NONE, parentStoryId: ParentStoryId? = null, giftBadge: GiftBadge? = null, - payment: Payment? = null + payment: Payment? = null, + call: CallTable.Call? = null ): MediaMmsMessageRecord { return MediaMmsMessageRecord( id, @@ -173,7 +174,8 @@ object FakeMessageRecords { storyType, parentStoryId, giftBadge, - payment + payment, + call ) } } diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index 97bf033563..54185cb329 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -67,6 +67,10 @@ fun Cursor.requireObject(column: String, serializer: StringSerializer): T return serializer.deserialize(CursorUtil.requireString(this, column)) } +fun Cursor.requireObject(column: String, serializer: IntSerializer): T { + return serializer.deserialize(CursorUtil.requireInt(this, column)) +} + @JvmOverloads fun Cursor.readToSingleLong(defaultValue: Long = 0): Long { return use { @@ -78,6 +82,16 @@ fun Cursor.readToSingleLong(defaultValue: Long = 0): Long { } } +fun Cursor.readToSingleObject(serializer: Serializer): T? { + return use { + if (it.moveToFirst()) { + serializer.deserialize(it) + } else { + null + } + } +} + @JvmOverloads fun Cursor.readToSingleInt(defaultValue: Int = 0): Int { return use { 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 fdcd161748..916ae0008c 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 @@ -88,6 +88,10 @@ class SelectBuilderPart2( return SelectBuilderPart3(db, columns, tableName, where, SqlUtil.buildArgs(*whereArgs)) } + fun where(where: String, whereArgs: Array): SelectBuilderPart3 { + return SelectBuilderPart3(db, columns, tableName, where, whereArgs) + } + fun run(): Cursor { return db.query( SupportSQLiteQueryBuilder diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index fb1a6bea18..07d8748cb1 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -600,6 +600,8 @@ public class SignalServiceMessageSender { urgent = message.getRequest().get().isUrgent(); } else if (message.getPniIdentity().isPresent()) { content = createPniIdentityContent(message.getPniIdentity().get()); + } else if (message.getCallEvent().isPresent()) { + content = createCallEventContent(message.getCallEvent().get()); } else { throw new IOException("Unsupported sync message!"); } @@ -1513,14 +1515,21 @@ public class SignalServiceMessageSender { } Content.Builder container = Content.newBuilder(); - SyncMessage.Builder builder = SyncMessage.newBuilder().setRequest(request); + SyncMessage.Builder builder = createSyncMessageBuilder().setRequest(request); return container.setSyncMessage(builder).build(); } private Content createPniIdentityContent(SyncMessage.PniIdentity proto) { Content.Builder container = Content.newBuilder(); - SyncMessage.Builder builder = SyncMessage.newBuilder().setPniIdentity(proto); + SyncMessage.Builder builder = createSyncMessageBuilder().setPniIdentity(proto); + + return container.setSyncMessage(builder).build(); + } + + private Content createCallEventContent(SyncMessage.CallEvent proto) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder builder = createSyncMessageBuilder().setCallEvent(proto); return container.setSyncMessage(builder).build(); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index 032dc525f5..f93dd49949 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -998,6 +998,10 @@ import javax.annotation.Nullable; return SignalServiceSyncMessage.forContacts(new ContactsMessage(createAttachmentPointer(content.getContacts().getBlob()), content.getContacts().getComplete())); } + if (content.hasCallEvent()) { + return SignalServiceSyncMessage.forCallEvent(content.getCallEvent()); + } + return SignalServiceSyncMessage.empty(); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java index 703789c335..ad8b87b599 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java @@ -8,12 +8,15 @@ package org.whispersystems.signalservice.api.messages.multidevice; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.PniIdentity; import java.util.LinkedList; import java.util.List; import java.util.Optional; +import javax.annotation.Nonnull; + public class SignalServiceSyncMessage { private final Optional sent; @@ -32,23 +35,25 @@ public class SignalServiceSyncMessage { private final Optional outgoingPaymentMessage; private final Optional pniIdentity; private final Optional> views; + private final Optional callEvent; - private SignalServiceSyncMessage(Optional sent, - Optional contacts, - Optional groups, - Optional blockedList, - Optional request, - Optional> reads, - Optional viewOnceOpen, - Optional verified, - Optional configuration, + private SignalServiceSyncMessage(Optional sent, + Optional contacts, + Optional groups, + Optional blockedList, + Optional request, + Optional> reads, + Optional viewOnceOpen, + Optional verified, + Optional configuration, Optional> stickerPackOperations, - Optional fetchType, - Optional keys, - Optional messageRequestResponse, - Optional outgoingPaymentMessage, - Optional> views, - Optional pniIdentity) + Optional fetchType, + Optional keys, + Optional messageRequestResponse, + Optional outgoingPaymentMessage, + Optional> views, + Optional pniIdentity, + Optional callEvent) { this.sent = sent; this.contacts = contacts; @@ -66,6 +71,7 @@ public class SignalServiceSyncMessage { this.outgoingPaymentMessage = outgoingPaymentMessage; this.views = views; this.pniIdentity = pniIdentity; + this.callEvent = callEvent; } public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) { @@ -84,6 +90,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -103,6 +110,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -122,6 +130,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -141,6 +150,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -160,6 +170,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -179,6 +190,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.of(views), + Optional.empty(), Optional.empty()); } @@ -198,6 +210,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -220,6 +233,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -239,6 +253,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -258,6 +273,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -277,6 +293,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -296,6 +313,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -315,6 +333,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -334,6 +353,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -353,6 +373,7 @@ public class SignalServiceSyncMessage { Optional.of(messageRequestResponse), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -372,6 +393,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.of(outgoingPaymentMessage), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -391,7 +413,28 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), - Optional.of(pniIdentity)); + Optional.of(pniIdentity), + Optional.empty()); + } + + public static SignalServiceSyncMessage forCallEvent(@Nonnull CallEvent callEvent) { + return new SignalServiceSyncMessage(Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of(callEvent)); } public static SignalServiceSyncMessage empty() { @@ -410,6 +453,7 @@ public class SignalServiceSyncMessage { Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty()); } @@ -477,6 +521,10 @@ public class SignalServiceSyncMessage { return pniIdentity; } + public Optional getCallEvent() { + return callEvent; + } + public enum FetchType { LOCAL_PROFILE, STORAGE_MANIFEST, diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index 3aad3d93b9..a838a28054 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -590,6 +590,33 @@ message SyncMessage { optional uint32 registrationId = 3; } + message CallEvent { + enum Type { + UNKNOWN_TYPE = 0; + AUDIO_CALL = 1; + VIDEO_CALL = 2; + } + + enum Direction { + UNKNOWN_DIRECTION = 0; + INCOMING = 1; + OUTGOING = 2; + } + + enum Event { + UNKNOWN_ACTION = 0; + ACCEPTED = 1; + NOT_ACCEPTED = 2; + } + + optional bytes peerUuid = 1; + optional uint64 id = 2; + optional uint64 timestamp = 3; + optional Type type = 4; + optional Direction direction = 5; + optional Event event = 6; + } + optional Sent sent = 1; optional Contacts contacts = 2; optional Groups groups = 3; @@ -608,6 +635,7 @@ message SyncMessage { repeated Viewed viewed = 16; optional PniIdentity pniIdentity = 17; optional PniChangeNumber pniChangeNumber = 18; + optional CallEvent callEvent = 19; } message AttachmentPointer {