mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 16:49:40 +01:00
Release polls behind feature flag.
This commit is contained in:
@@ -80,6 +80,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipt
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mentions
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.polls
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.storySends
|
||||
@@ -110,6 +111,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
@@ -127,6 +129,8 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier.StickyThread
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo
|
||||
@@ -211,6 +215,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val ORIGINAL_MESSAGE_ID = "original_message_id"
|
||||
const val REVISION_NUMBER = "revision_number"
|
||||
const val MESSAGE_EXTRAS = "message_extras"
|
||||
const val VOTES_UNREAD = "votes_unread"
|
||||
const val VOTES_LAST_SEEN = "votes_last_seen"
|
||||
|
||||
const val QUOTE_NOT_PRESENT_ID = 0L
|
||||
const val QUOTE_TARGET_MISSING_ID = -1L
|
||||
@@ -273,7 +279,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$ORIGINAL_MESSAGE_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
|
||||
$REVISION_NUMBER INTEGER DEFAULT 0,
|
||||
$MESSAGE_EXTRAS BLOB DEFAULT NULL,
|
||||
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL
|
||||
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL,
|
||||
$VOTES_UNREAD INTEGER DEFAULT 0,
|
||||
$VOTES_LAST_SEEN INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -303,7 +311,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
// This index is created specifically for getting the number of messages in a thread and therefore needs to be kept in sync with that query
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL",
|
||||
// This index is created specifically for getting the number of unread messages in a thread and therefore needs to be kept in sync with that query
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0"
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0",
|
||||
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)"
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION_BASE = arrayOf(
|
||||
@@ -356,7 +365,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
LATEST_REVISION_ID,
|
||||
ORIGINAL_MESSAGE_ID,
|
||||
REVISION_NUMBER,
|
||||
MESSAGE_EXTRAS
|
||||
MESSAGE_EXTRAS,
|
||||
VOTES_UNREAD,
|
||||
VOTES_LAST_SEEN
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}"
|
||||
@@ -2211,9 +2222,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
reactions.deleteReactions(MessageId(messageId))
|
||||
deleteGroupStoryReplies(messageId)
|
||||
disassociateStoryQuotes(messageId)
|
||||
disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId))
|
||||
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
threads.update(threadId, false)
|
||||
notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
OptimizeMessageSearchIndexJob.enqueue()
|
||||
@@ -2303,7 +2316,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
NOTIFIED to 1,
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis()
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis(),
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
.where("$ID = ? OR $ORIGINAL_MESSAGE_ID = ? OR $LATEST_REVISION_ID = ?", id, id, id)
|
||||
.run()
|
||||
@@ -2351,6 +2365,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
(
|
||||
$REACTIONS_UNREAD = 1 AND
|
||||
($outgoingTypeClause)
|
||||
) OR
|
||||
(
|
||||
$VOTES_UNREAD = 1 AND
|
||||
($outgoingTypeClause)
|
||||
)
|
||||
)
|
||||
"""
|
||||
@@ -2424,7 +2442,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
|
||||
fun setAllMessagesRead(): List<MarkedMessageInfo> {
|
||||
return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND ($outgoingTypeClause)))", null)
|
||||
return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND ($outgoingTypeClause)) OR ($VOTES_UNREAD = 1 AND ($outgoingTypeClause)))", null)
|
||||
}
|
||||
|
||||
private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> {
|
||||
@@ -2432,7 +2450,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
return writableDatabase.rawQuery(
|
||||
"""
|
||||
UPDATE $TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID
|
||||
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}
|
||||
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}, $VOTES_UNREAD = 0, $VOTES_LAST_SEEN = ${System.currentTimeMillis()}
|
||||
WHERE $where
|
||||
RETURNING $ID, $FROM_RECIPIENT_ID, $DATE_SENT, $DATE_RECEIVED, $TYPE, $EXPIRES_IN, $EXPIRE_STARTED, $THREAD_ID, $STORY_TYPE
|
||||
""",
|
||||
@@ -2526,6 +2544,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
fun getOutgoingMessage(messageId: Long): OutgoingMessage {
|
||||
return rawQueryWithAttachments(RAW_ID_WHERE, arrayOf(messageId.toString())).readToSingleObject { cursor ->
|
||||
val associatedAttachments = attachments.getAttachmentsForMessage(messageId)
|
||||
val associatedPoll = polls.getPollForOutgoingMessage(messageId)
|
||||
val mentions = mentions.getMentionsForMessage(messageId)
|
||||
val outboxType = cursor.requireLong(TYPE)
|
||||
val body = cursor.requireString(BODY)
|
||||
@@ -2655,6 +2674,20 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn
|
||||
)
|
||||
} else if (associatedPoll != null) {
|
||||
OutgoingMessage.pollMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn,
|
||||
poll = associatedPoll
|
||||
)
|
||||
} else if (MessageTypes.isPollTerminate(outboxType) && messageExtras != null) {
|
||||
OutgoingMessage.pollTerminateMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras
|
||||
)
|
||||
} else {
|
||||
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) {
|
||||
GiftBadge.ADAPTER.decode(Base64.decode(body))
|
||||
@@ -2806,7 +2839,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
contentValues = contentValues,
|
||||
insertListener = null,
|
||||
updateThread = retrieved.storyType === StoryType.NONE && !silent,
|
||||
unarchive = true
|
||||
unarchive = true,
|
||||
poll = retrieved.poll,
|
||||
pollTerminate = retrieved.messageExtras?.pollTerminate
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3128,6 +3163,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
if (message.messageExtras?.pollTerminate != null) {
|
||||
if (hasSpecialType) {
|
||||
throw MmsException("Cannot insert message with multiple special types.")
|
||||
}
|
||||
type = type or MessageTypes.SPECIAL_TYPE_POLL_TERMINATE
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
val earlyDeliveryReceipts: Map<RecipientId, Receipt> = earlyDeliveryReceiptCache.remove(message.sentTimeMillis)
|
||||
|
||||
if (earlyDeliveryReceipts.isNotEmpty()) {
|
||||
@@ -3241,7 +3284,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
contentValues = contentValues,
|
||||
insertListener = insertListener,
|
||||
updateThread = false,
|
||||
unarchive = false
|
||||
unarchive = false,
|
||||
poll = message.poll,
|
||||
pollTerminate = message.messageExtras?.pollTerminate
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3348,7 +3393,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
contentValues: ContentValues,
|
||||
insertListener: InsertListener?,
|
||||
updateThread: Boolean,
|
||||
unarchive: Boolean
|
||||
unarchive: Boolean,
|
||||
poll: Poll? = null,
|
||||
pollTerminate: PollTerminate? = null
|
||||
): kotlin.Pair<Long, Map<Attachment, AttachmentId>?> {
|
||||
val mentionsSelf = mentions.any { Recipient.resolved(it.recipientId).isSelf }
|
||||
val allAttachments: MutableList<Attachment> = mutableListOf()
|
||||
@@ -3401,6 +3448,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
if (poll != null) {
|
||||
polls.insertPoll(poll.question, poll.allowMultipleVotes, poll.pollOptions, poll.authorId, messageId)
|
||||
}
|
||||
|
||||
if (pollTerminate != null) {
|
||||
val pollId = polls.getPollId(pollTerminate.messageId)
|
||||
if (pollId == null) {
|
||||
Log.w(TAG, "Unable to find corresponding poll.")
|
||||
} else {
|
||||
polls.endPoll(pollId, messageId)
|
||||
}
|
||||
}
|
||||
|
||||
messageId to insertedAttachments
|
||||
}
|
||||
|
||||
@@ -3486,6 +3546,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
attachments.deleteAttachmentsForMessage(messageId)
|
||||
groupReceipts.deleteRowsForMessage(messageId)
|
||||
mentions.deleteMentionsForMessage(messageId)
|
||||
disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId))
|
||||
|
||||
writableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
@@ -3551,6 +3612,36 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a poll gets deleted, remove the poll reference from its corresponding terminate message by setting it to -1.
|
||||
*/
|
||||
fun disassociatePollFromPollTerminate(messageId: Long) {
|
||||
if (messageId == -1L) {
|
||||
return
|
||||
}
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
val messageExtras = db
|
||||
.select(MESSAGE_EXTRAS)
|
||||
.from(TABLE_NAME)
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
.readToSingleObject { cursor ->
|
||||
val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS)
|
||||
messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
}
|
||||
|
||||
if (messageExtras?.pollTerminate != null) {
|
||||
val updatedMessageExtras = messageExtras.newBuilder().pollTerminate(pollTerminate = messageExtras.pollTerminate.copy(messageId = -1)).build()
|
||||
db
|
||||
.update(TABLE_NAME)
|
||||
.values(MESSAGE_EXTRAS to updatedMessageExtras.encode())
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSerializedSharedContacts(insertedAttachmentIds: Map<Attachment, AttachmentId>, contacts: List<Contact>): String? {
|
||||
if (contacts.isEmpty()) {
|
||||
return null
|
||||
@@ -4048,6 +4139,34 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
.run()
|
||||
}
|
||||
|
||||
fun setVoteSeen(threadId: Long, sinceTimestamp: Long) {
|
||||
val where = if (sinceTimestamp > -1) {
|
||||
"$THREAD_ID = ? AND $VOTES_UNREAD = ? AND $DATE_RECEIVED <= $sinceTimestamp"
|
||||
} else {
|
||||
"$THREAD_ID = ? AND $VOTES_UNREAD = ?"
|
||||
}
|
||||
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
VOTES_UNREAD to 0,
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
.where(where, threadId, 1)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun setAllVotesSeen() {
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
VOTES_UNREAD to 0,
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
.where("$VOTES_UNREAD != ?", 0)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun setNotifiedTimestamp(timestamp: Long, ids: List<Long>) {
|
||||
if (ids.isEmpty()) {
|
||||
return
|
||||
@@ -4830,7 +4949,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
val values = contentValuesOf(
|
||||
READ to 1,
|
||||
REACTIONS_UNREAD to 0,
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis()
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis(),
|
||||
VOTES_UNREAD to 0,
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
|
||||
if (expiresIn > 0) {
|
||||
@@ -4975,7 +5096,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
($READ = 0 AND ($ORIGINAL_MESSAGE_ID IS NULL OR EXISTS (SELECT 1 FROM $TABLE_NAME AS m WHERE m.$ID = $TABLE_NAME.$ORIGINAL_MESSAGE_ID AND m.$READ = 0)))
|
||||
OR $REACTIONS_UNREAD = 1
|
||||
${if (stickyQuery.isNotEmpty()) "OR ($stickyQuery)" else ""}
|
||||
OR ($IS_MISSED_CALL_TYPE_CLAUSE AND EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.MESSAGE_ID} = $TABLE_NAME.$ID AND ${CallTable.EVENT} = ${CallTable.Event.serialize(CallTable.Event.MISSED)} AND ${CallTable.READ} = 0))
|
||||
OR ($IS_MISSED_CALL_TYPE_CLAUSE AND EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.MESSAGE_ID} = $TABLE_NAME.$ID AND ${CallTable.EVENT} = ${CallTable.Event.serialize(CallTable.Event.MISSED)} AND ${CallTable.READ} = 0))
|
||||
OR $VOTES_UNREAD = 1
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -5139,6 +5261,32 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun updateVotesUnread(db: SQLiteDatabase, messageId: Long, hasVotes: Boolean, isRemoval: Boolean) {
|
||||
try {
|
||||
val isOutgoing = getMessageRecord(messageId).isOutgoing
|
||||
val values = ContentValues()
|
||||
|
||||
if (!hasVotes) {
|
||||
values.put(VOTES_UNREAD, 0)
|
||||
} else if (!isRemoval) {
|
||||
values.put(VOTES_UNREAD, 1)
|
||||
}
|
||||
|
||||
if (isOutgoing && hasVotes) {
|
||||
values.put(NOTIFIED, 0)
|
||||
}
|
||||
|
||||
if (values.size() > 0) {
|
||||
db.update(TABLE_NAME)
|
||||
.values(values)
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
}
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Failed to find message $messageId")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected fun <D : Document<I>?, I> removeFromDocument(messageId: Long, column: String, item: I, clazz: Class<D>) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
@@ -5295,6 +5443,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
MessageType.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.END_SESSION -> MessageTypes.END_SESSION_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.POLL_TERMINATE -> MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.GROUP_UPDATE -> {
|
||||
val isOnlyGroupLeave = this.groupContext?.let { GroupV2UpdateMessageUtil.isJustAGroupLeave(it) } ?: false
|
||||
|
||||
@@ -5635,6 +5784,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
null
|
||||
}
|
||||
|
||||
val poll: PollRecord? = polls.getPoll(id)
|
||||
|
||||
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(box)) {
|
||||
try {
|
||||
GiftBadge.ADAPTER.decode(Base64.decode(body))
|
||||
@@ -5683,6 +5834,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
giftBadge,
|
||||
null,
|
||||
null,
|
||||
poll,
|
||||
scheduledDate,
|
||||
latestRevisionId,
|
||||
originalMessageId,
|
||||
|
||||
@@ -45,5 +45,8 @@ enum class MessageType {
|
||||
IDENTITY_DEFAULT,
|
||||
|
||||
/** A manual session reset. This is no longer used and is only here for handling possible inbound/sync messages. */
|
||||
END_SESSION
|
||||
END_SESSION,
|
||||
|
||||
/** A poll has ended **/
|
||||
POLL_TERMINATE
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ public interface MessageTypes {
|
||||
long SPECIAL_TYPE_PAYMENTS_TOMBSTONE = 0x900000000L;
|
||||
long SPECIAL_TYPE_BLOCKED = 0xA00000000L;
|
||||
long SPECIAL_TYPE_UNBLOCKED = 0xB00000000L;
|
||||
long SPECIAL_TYPE_POLL_TERMINATE = 0xC00000000L;
|
||||
|
||||
long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT;
|
||||
|
||||
@@ -165,6 +166,10 @@ public interface MessageTypes {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_UNBLOCKED;
|
||||
}
|
||||
|
||||
static boolean isPollTerminate(long type) {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_POLL_TERMINATE;
|
||||
}
|
||||
|
||||
static boolean isDraftMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.groupBy
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToMap
|
||||
import org.signal.core.util.readToSingleBoolean
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.readToSingleLongOrNull
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.polls.PollVote
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Database table for polls
|
||||
*
|
||||
* Voting:
|
||||
* [VOTE_COUNT] tracks how often someone has voted and is specific per poll and user.
|
||||
* The first time Alice votes in Poll 1, the count is 1, the next time is 2. Removing a vote will also bump it (to 3).
|
||||
* If Alice votes in Poll 2, her vote count will start at 1. If Bob votes, his own vote count starts at 1 (so no interactions between other polls or people)
|
||||
* We track vote count because the server can reorder messages and we don't want to process an older vote count than what we have.
|
||||
*
|
||||
* For example, in three rounds of voting (in the same poll):
|
||||
* 1. Alice votes for option a -> we send (a) with vote count of 1
|
||||
* 2. Alice votes for option b -> we send (a,b) with vote count of 2
|
||||
* 3. Alice removes option b -> we send (a) with vote count of 3
|
||||
*
|
||||
* If we get and process #3 before receiving #2, we will drop #2. This can be done because the voting message always contains the full state of all your votes.
|
||||
*
|
||||
* [VOTE_STATE] tracks the lifecycle of a single vote. Example below with added (remove is very similar).
|
||||
* UI: Alice votes for Option A -> Pending Spinner on Option A -> Option A is checked/Option B is removed if single-vote poll.
|
||||
* BTS: PollVoteJob runs (PENDING_ADD) PollVoteJob finishes (ADDED)
|
||||
*/
|
||||
class PollTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), RecipientIdDatabaseReference {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PollTables::class.java)
|
||||
|
||||
@JvmField
|
||||
val CREATE_TABLE: Array<String> = arrayOf(PollTable.CREATE_TABLE, PollOptionTable.CREATE_TABLE, PollVoteTable.CREATE_TABLE)
|
||||
|
||||
@JvmField
|
||||
val CREATE_INDEXES: Array<String> = PollTable.CREATE_INDEXES + PollOptionTable.CREATE_INDEXES + PollVoteTable.CREATE_INDEXES
|
||||
}
|
||||
|
||||
/**
|
||||
* Table containing general poll information (name, deleted status, etc.)
|
||||
*/
|
||||
object PollTable {
|
||||
const val TABLE_NAME = "poll"
|
||||
|
||||
const val ID = "_id"
|
||||
const val AUTHOR_ID = "author_id"
|
||||
const val MESSAGE_ID = "message_id"
|
||||
const val QUESTION = "question"
|
||||
const val ALLOW_MULTIPLE_VOTES = "allow_multiple_votes"
|
||||
const val END_MESSAGE_ID = "end_message_id"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$AUTHOR_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
|
||||
$MESSAGE_ID INTEGER NOT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE CASCADE,
|
||||
$QUESTION TEXT,
|
||||
$ALLOW_MULTIPLE_VOTES INTEGER DEFAULT 0,
|
||||
$END_MESSAGE_ID INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX poll_author_id_index ON $TABLE_NAME ($AUTHOR_ID)",
|
||||
"CREATE INDEX poll_message_id_index ON $TABLE_NAME ($MESSAGE_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Table containing the options within a given poll
|
||||
*/
|
||||
object PollOptionTable {
|
||||
const val TABLE_NAME = "poll_option"
|
||||
|
||||
const val ID = "_id"
|
||||
const val POLL_ID = "poll_id"
|
||||
const val OPTION_TEXT = "option_text"
|
||||
const val OPTION_ORDER = "option_order"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$POLL_ID INTEGER NOT NULL REFERENCES ${PollTable.TABLE_NAME} (${PollTable.ID}) ON DELETE CASCADE,
|
||||
$OPTION_TEXT TEXT,
|
||||
$OPTION_ORDER INTEGER
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX poll_option_poll_id_index ON $TABLE_NAME ($POLL_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Table containing the votes of a given poll
|
||||
*/
|
||||
object PollVoteTable {
|
||||
const val TABLE_NAME = "poll_vote"
|
||||
|
||||
const val ID = "_id"
|
||||
const val POLL_ID = "poll_id"
|
||||
const val POLL_OPTION_ID = "poll_option_id"
|
||||
const val VOTER_ID = "voter_id"
|
||||
const val VOTE_COUNT = "vote_count"
|
||||
const val DATE_RECEIVED = "date_received"
|
||||
const val VOTE_STATE = "vote_state"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$POLL_ID INTEGER NOT NULL REFERENCES ${PollTable.TABLE_NAME} (${PollTable.ID}) ON DELETE CASCADE,
|
||||
$POLL_OPTION_ID INTEGER DEFAULT NULL REFERENCES ${PollOptionTable.TABLE_NAME} (${PollOptionTable.ID}) ON DELETE CASCADE,
|
||||
$VOTER_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
|
||||
$VOTE_COUNT INTEGER,
|
||||
$DATE_RECEIVED INTEGER DEFAULT 0,
|
||||
$VOTE_STATE INTEGER DEFAULT 0,
|
||||
UNIQUE($POLL_ID, $VOTER_ID, $POLL_OPTION_ID) ON CONFLICT REPLACE
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX poll_vote_poll_id_index ON $TABLE_NAME ($POLL_ID)",
|
||||
"CREATE INDEX poll_vote_poll_option_id_index ON $TABLE_NAME ($POLL_OPTION_ID)",
|
||||
"CREATE INDEX poll_vote_voter_id_index ON $TABLE_NAME ($VOTER_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
|
||||
val countFromPoll = writableDatabase
|
||||
.update(PollTable.TABLE_NAME)
|
||||
.values(PollTable.AUTHOR_ID to toId.serialize())
|
||||
.where("${PollTable.AUTHOR_ID} = ?", fromId)
|
||||
.run()
|
||||
|
||||
val countFromVotes = writableDatabase
|
||||
.update(PollVoteTable.TABLE_NAME)
|
||||
.values(PollVoteTable.VOTER_ID to toId.serialize())
|
||||
.where("${PollVoteTable.VOTER_ID} = ?", fromId)
|
||||
.run()
|
||||
|
||||
Log.d(TAG, "Remapped $fromId to $toId. count from polls: $countFromPoll from poll votes: $countFromVotes")
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a newly created poll with its options
|
||||
*/
|
||||
fun insertPoll(question: String, allowMultipleVotes: Boolean, options: List<String>, authorId: Long, messageId: Long) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
val pollId = db.insertInto(PollTable.TABLE_NAME)
|
||||
.values(
|
||||
contentValuesOf(
|
||||
PollTable.QUESTION to question,
|
||||
PollTable.ALLOW_MULTIPLE_VOTES to allowMultipleVotes,
|
||||
PollTable.AUTHOR_ID to authorId,
|
||||
PollTable.MESSAGE_ID to messageId
|
||||
)
|
||||
)
|
||||
.run()
|
||||
|
||||
SqlUtil.buildBulkInsert(
|
||||
PollOptionTable.TABLE_NAME,
|
||||
arrayOf(PollOptionTable.POLL_ID, PollOptionTable.OPTION_TEXT, PollOptionTable.OPTION_ORDER),
|
||||
options.toPollContentValues(pollId)
|
||||
).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a vote in a poll and increases the vote count by 1.
|
||||
* Status is marked as [VoteState.PENDING_ADD] here and then once it successfully sends, it will get updated to [VoteState.ADDED] in [markPendingAsAdded]
|
||||
*/
|
||||
fun insertVote(poll: PollRecord, pollOption: PollOption): Int {
|
||||
val self = Recipient.self().id.toLong()
|
||||
var voteCount = 0
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
voteCount = getCurrentPollVoteCount(poll.id, self) + 1
|
||||
val contentValues = ContentValues().apply {
|
||||
put(PollVoteTable.POLL_ID, poll.id)
|
||||
put(PollVoteTable.POLL_OPTION_ID, pollOption.id)
|
||||
put(PollVoteTable.VOTER_ID, self)
|
||||
put(PollVoteTable.VOTE_COUNT, voteCount)
|
||||
put(PollVoteTable.VOTE_STATE, VoteState.PENDING_ADD.value)
|
||||
}
|
||||
|
||||
db.insertInto(PollVoteTable.TABLE_NAME)
|
||||
.values(contentValues)
|
||||
.run(SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(poll.messageId))
|
||||
return voteCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a vote is sent to at least one person, we can update the [VoteState.PENDING_ADD] state to [VoteState.ADDED].
|
||||
* If the poll only allows one vote, it also clears out any old votes.
|
||||
*/
|
||||
fun markPendingAsAdded(pollId: Long, voterId: Long, voteCount: Int, messageId: Long) {
|
||||
val poll = SignalDatabase.polls.getPollFromId(pollId)
|
||||
if (poll == null) {
|
||||
Log.w(TAG, "Cannot find poll anymore $pollId")
|
||||
return
|
||||
}
|
||||
|
||||
writableDatabase.updateWithOnConflict(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
contentValuesOf(PollVoteTable.VOTE_STATE to VoteState.ADDED.value),
|
||||
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ? AND ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value}",
|
||||
SqlUtil.buildArgs(pollId, voterId, voteCount),
|
||||
SQLiteDatabase.CONFLICT_REPLACE
|
||||
)
|
||||
|
||||
if (!poll.allowMultipleVotes) {
|
||||
writableDatabase.delete(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} < ?", poll.id, Recipient.self().id, voteCount)
|
||||
.run()
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes vote from a poll. This also increases the vote count because removal of a vote, is technically a type of vote.
|
||||
* Status is marked as [VoteState.PENDING_REMOVE] here and then once it successfully sends, it will get updated to [VoteState.REMOVED] in [markPendingAsRemoved]
|
||||
*/
|
||||
fun removeVote(poll: PollRecord, pollOption: PollOption): Int {
|
||||
val self = Recipient.self().id.toLong()
|
||||
var voteCount = 0
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
voteCount = getCurrentPollVoteCount(poll.id, self) + 1
|
||||
db.insertInto(PollVoteTable.TABLE_NAME)
|
||||
.values(
|
||||
PollVoteTable.POLL_ID to poll.id,
|
||||
PollVoteTable.POLL_OPTION_ID to pollOption.id,
|
||||
PollVoteTable.VOTER_ID to self,
|
||||
PollVoteTable.VOTE_COUNT to voteCount,
|
||||
PollVoteTable.VOTE_STATE to VoteState.PENDING_REMOVE.value
|
||||
)
|
||||
.run(SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(poll.messageId))
|
||||
return voteCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a vote is sent to at least one person, we can update the [VoteState.PENDING_REMOVE] state to [VoteState.REMOVED].
|
||||
*/
|
||||
fun markPendingAsRemoved(pollId: Long, voterId: Long, voteCount: Int, messageId: Long) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.updateWithOnConflict(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
contentValuesOf(PollVoteTable.VOTE_STATE to VoteState.REMOVED.value),
|
||||
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ? AND ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value}",
|
||||
SqlUtil.buildArgs(pollId, voterId, voteCount),
|
||||
SQLiteDatabase.CONFLICT_REPLACE
|
||||
)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given poll, returns the option indexes that the person has voted for
|
||||
*/
|
||||
fun getVotes(pollId: Long, allowMultipleVotes: Boolean): List<Int> {
|
||||
val voteQuery = if (allowMultipleVotes) {
|
||||
"(${PollVoteTable.VOTE_STATE} = ${VoteState.ADDED.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value})"
|
||||
} else {
|
||||
"${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value}"
|
||||
}
|
||||
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.OPTION_ORDER)
|
||||
.from("${PollVoteTable.TABLE_NAME} LEFT JOIN ${PollOptionTable.TABLE_NAME} ON ${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_OPTION_ID} = ${PollOptionTable.TABLE_NAME}.${PollOptionTable.ID}")
|
||||
.where(
|
||||
"""
|
||||
${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_ID} = ? AND
|
||||
${PollVoteTable.VOTER_ID} = ? AND
|
||||
${PollVoteTable.POLL_OPTION_ID} IS NOT NULL AND
|
||||
$voteQuery
|
||||
""",
|
||||
pollId,
|
||||
Recipient.self().id.toLong()
|
||||
)
|
||||
.run()
|
||||
.readToList { cursor -> cursor.requireInt(PollOptionTable.OPTION_ORDER) }
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given poll, returns who has voted in the poll. If a person has voted for multiple options, only count their most recent vote.
|
||||
*/
|
||||
fun getAllVotes(messageId: Long): List<PollVote> {
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from("${PollTable.TABLE_NAME} INNER JOIN ${PollVoteTable.TABLE_NAME} ON ${PollTable.TABLE_NAME}.${PollTable.ID} = ${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_ID}")
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.orderBy("${PollVoteTable.DATE_RECEIVED} DESC")
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
PollVote(
|
||||
pollId = cursor.requireLong(PollVoteTable.POLL_ID),
|
||||
question = cursor.requireNonNullString(PollTable.QUESTION),
|
||||
voterId = RecipientId.from(cursor.requireLong(PollVoteTable.VOTER_ID)),
|
||||
dateReceived = cursor.requireLong(PollVoteTable.DATE_RECEIVED)
|
||||
)
|
||||
}
|
||||
.distinctBy { it.pollId to it.voterId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [VoteState] for a given voting session (as indicated by voteCount)
|
||||
*/
|
||||
fun getPollVoteStateForGivenVote(pollId: Long, voteCount: Int): VoteState {
|
||||
val value = readableDatabase
|
||||
.select(PollVoteTable.VOTE_STATE)
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ?", pollId, Recipient.self().id.toLong(), voteCount)
|
||||
.run()
|
||||
.readToSingleInt()
|
||||
return VoteState.fromValue(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the [VoteState] for a given voting session (as indicated by voteCount)
|
||||
*/
|
||||
fun setPollVoteStateForGivenVote(pollId: Long, voterId: Long, voteCount: Int, messageId: Long, undoRemoval: Boolean) {
|
||||
val state = if (undoRemoval) VoteState.ADDED.value else VoteState.REMOVED.value
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.updateWithOnConflict(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
contentValuesOf(
|
||||
PollVoteTable.VOTE_STATE to state
|
||||
),
|
||||
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ?",
|
||||
SqlUtil.buildArgs(pollId, voterId, voteCount),
|
||||
SQLiteDatabase.CONFLICT_REPLACE
|
||||
)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts all of the votes a person has made on a poll. Clears out any old data if they voted previously.
|
||||
*/
|
||||
fun insertVotes(pollId: Long, pollOptionIds: List<Long>, voterId: Long, voteCount: Long, messageId: MessageId) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
// Delete any previous votes they had on the poll
|
||||
db.delete(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.POLL_ID} = ?", voterId, pollId)
|
||||
.run()
|
||||
|
||||
SqlUtil.buildBulkInsert(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
arrayOf(PollVoteTable.POLL_ID, PollVoteTable.POLL_OPTION_ID, PollVoteTable.VOTER_ID, PollVoteTable.VOTE_COUNT, PollVoteTable.DATE_RECEIVED, PollVoteTable.VOTE_STATE),
|
||||
pollOptionIds.toPollVoteContentValues(pollId, voterId, voteCount)
|
||||
).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(messageId)
|
||||
SignalDatabase.messages.updateVotesUnread(writableDatabase, messageId.id, hasVotes(pollId), pollOptionIds.isEmpty())
|
||||
}
|
||||
|
||||
private fun hasVotes(pollId: Long): Boolean {
|
||||
return readableDatabase
|
||||
.exists(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ?", pollId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* If a poll has ended, returns the message id of the poll end message. Otherwise, return -1.
|
||||
*/
|
||||
fun getPollTerminateMessageId(messageId: Long): Long {
|
||||
return readableDatabase
|
||||
.select(PollTable.END_MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.run()
|
||||
.readToSingleLong(-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a poll
|
||||
*/
|
||||
fun endPoll(pollId: Long, endingMessageId: Long) {
|
||||
val messageId = getMessageId(pollId)
|
||||
if (messageId == null) {
|
||||
Log.w(TAG, "Unable to find the poll to end.")
|
||||
return
|
||||
}
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.update(PollTable.TABLE_NAME)
|
||||
.values(PollTable.END_MESSAGE_ID to endingMessageId)
|
||||
.where("${PollTable.ID} = ?", pollId)
|
||||
.run()
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the poll id if associated with a given message id
|
||||
*/
|
||||
fun getPollId(messageId: Long): Long? {
|
||||
return readableDatabase
|
||||
.select(PollTable.ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.run()
|
||||
.readToSingleLongOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message id for a poll id
|
||||
*/
|
||||
fun getMessageId(pollId: Long): Long? {
|
||||
return readableDatabase
|
||||
.select(PollTable.MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.ID} = ?", pollId)
|
||||
.run()
|
||||
.readToSingleLongOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a poll record for a given poll id
|
||||
*/
|
||||
fun getPollFromId(pollId: Long): PollRecord? {
|
||||
return getPoll(getMessageId(pollId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the minimum amount necessary to create a poll for a message id
|
||||
*/
|
||||
fun getPollForOutgoingMessage(messageId: Long): Poll? {
|
||||
return readableDatabase.withinTransaction { db ->
|
||||
db.select(PollTable.ID, PollTable.QUESTION, PollTable.ALLOW_MULTIPLE_VOTES)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.run()
|
||||
.readToSingleObject { cursor ->
|
||||
val pollId = cursor.requireLong(PollTable.ID)
|
||||
Poll(
|
||||
question = cursor.requireString(PollTable.QUESTION) ?: "",
|
||||
allowMultipleVotes = cursor.requireBoolean(PollTable.ALLOW_MULTIPLE_VOTES),
|
||||
pollOptions = getPollOptionText(pollId),
|
||||
authorId = Recipient.self().id.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the poll if associated with a given message id
|
||||
*/
|
||||
fun getPoll(messageId: Long?): PollRecord? {
|
||||
return if (messageId != null) {
|
||||
getPollsForMessages(listOf(messageId))[messageId]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps message ids to its associated poll (if it exists)
|
||||
*/
|
||||
fun getPollsForMessages(messageIds: Collection<Long>): Map<Long, PollRecord> {
|
||||
if (messageIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val self = Recipient.self().id.toLong()
|
||||
val query = SqlUtil.buildFastCollectionQuery(PollTable.MESSAGE_ID, messageIds)
|
||||
return readableDatabase.withinTransaction { db ->
|
||||
db.select(PollTable.ID, PollTable.MESSAGE_ID, PollTable.QUESTION, PollTable.ALLOW_MULTIPLE_VOTES, PollTable.END_MESSAGE_ID, PollTable.AUTHOR_ID, PollTable.MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where(query.where, query.whereArgs)
|
||||
.run()
|
||||
.readToMap { cursor ->
|
||||
val pollId = cursor.requireLong(PollTable.ID)
|
||||
val pollVotes = getPollVotes(pollId)
|
||||
val pendingVotes = getPendingVotes(pollId)
|
||||
val pollOptions = getPollOptions(pollId).map { option ->
|
||||
val voterIds = pollVotes[option.key] ?: emptyList()
|
||||
PollOption(id = option.key, text = option.value, voterIds = voterIds, isSelected = voterIds.contains(self), isPending = pendingVotes.contains(option.key))
|
||||
}
|
||||
val poll = PollRecord(
|
||||
id = pollId,
|
||||
question = cursor.requireNonNullString(PollTable.QUESTION),
|
||||
pollOptions = pollOptions,
|
||||
allowMultipleVotes = cursor.requireBoolean(PollTable.ALLOW_MULTIPLE_VOTES),
|
||||
hasEnded = cursor.requireBoolean(PollTable.END_MESSAGE_ID),
|
||||
authorId = cursor.requireLong(PollTable.AUTHOR_ID),
|
||||
messageId = cursor.requireLong(PollTable.MESSAGE_ID)
|
||||
)
|
||||
cursor.requireLong(PollTable.MESSAGE_ID) to poll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a poll id, returns a list of all of the ids of its options
|
||||
*/
|
||||
fun getPollOptionIds(pollId: Long): List<Long> {
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.ID)
|
||||
.from(PollOptionTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ?", pollId)
|
||||
.orderBy(PollOptionTable.OPTION_ORDER)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
cursor.requireLong(PollOptionTable.ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a poll id and a voter id, return their vote count (how many times they have voted)
|
||||
*/
|
||||
fun getCurrentPollVoteCount(pollId: Long, voterId: Long): Int {
|
||||
return readableDatabase
|
||||
.select("MAX(${PollVoteTable.VOTE_COUNT})")
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ?", pollId, voterId)
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return if the poll supports multiple votes for options
|
||||
*/
|
||||
fun canAllowMultipleVotes(pollId: Long): Boolean {
|
||||
return readableDatabase
|
||||
.select(PollTable.ALLOW_MULTIPLE_VOTES)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.ID} = ? ", pollId)
|
||||
.run()
|
||||
.readToSingleBoolean()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the poll has ended
|
||||
*/
|
||||
fun hasEnded(pollId: Long): Boolean {
|
||||
return readableDatabase
|
||||
.select(PollTable.END_MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.ID} = ? ", pollId)
|
||||
.run()
|
||||
.readToSingleBoolean()
|
||||
}
|
||||
|
||||
private fun getPollOptions(pollId: Long): Map<Long, String> {
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.ID, PollOptionTable.OPTION_TEXT)
|
||||
.from(PollOptionTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ?", pollId)
|
||||
.run()
|
||||
.readToMap { cursor ->
|
||||
cursor.requireLong(PollOptionTable.ID) to cursor.requireNonNullString(PollOptionTable.OPTION_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPollVotes(pollId: Long): Map<Long, List<Long>> {
|
||||
return readableDatabase
|
||||
.select(PollVoteTable.POLL_OPTION_ID, PollVoteTable.VOTER_ID)
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND (${PollVoteTable.VOTE_STATE} = ${VoteState.ADDED.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value})", pollId)
|
||||
.run()
|
||||
.groupBy { cursor ->
|
||||
cursor.requireLong(PollVoteTable.POLL_OPTION_ID) to cursor.requireLong(PollVoteTable.VOTER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPendingVotes(pollId: Long): List<Long> {
|
||||
return readableDatabase
|
||||
.select(PollVoteTable.POLL_OPTION_ID)
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND (${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value})", pollId, Recipient.self().id)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
cursor.requireLong(PollVoteTable.POLL_OPTION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPollOptionText(pollId: Long): List<String> {
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.OPTION_TEXT)
|
||||
.from(PollOptionTable.TABLE_NAME)
|
||||
.where("${PollOptionTable.POLL_ID} = ?", pollId)
|
||||
.run()
|
||||
.readToList { it.requireString(PollOptionTable.OPTION_TEXT)!! }
|
||||
}
|
||||
|
||||
private fun <E> Collection<E>.toPollContentValues(pollId: Long): List<ContentValues> {
|
||||
return this.mapIndexed { index, option ->
|
||||
contentValuesOf(
|
||||
PollOptionTable.POLL_ID to pollId,
|
||||
PollOptionTable.OPTION_TEXT to option,
|
||||
PollOptionTable.OPTION_ORDER to index
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <E> Collection<E>.toPollVoteContentValues(pollId: Long, voterId: Long, voteCount: Long): List<ContentValues> {
|
||||
return this.map {
|
||||
contentValuesOf(
|
||||
PollVoteTable.POLL_ID to pollId,
|
||||
PollVoteTable.POLL_OPTION_ID to it,
|
||||
PollVoteTable.VOTER_ID to voterId,
|
||||
PollVoteTable.VOTE_COUNT to voteCount,
|
||||
PollVoteTable.DATE_RECEIVED to System.currentTimeMillis(),
|
||||
PollVoteTable.VOTE_STATE to VoteState.ADDED.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoteState(val value: Int) {
|
||||
/** We have no information on the vote state */
|
||||
NONE(0),
|
||||
|
||||
/** Vote is in the process of being removed */
|
||||
PENDING_REMOVE(1),
|
||||
|
||||
/** Vote is in the process of being added */
|
||||
PENDING_ADD(2),
|
||||
|
||||
/** Vote was removed */
|
||||
REMOVED(3),
|
||||
|
||||
/** Vote was added */
|
||||
ADDED(4);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int) = VoteState.entries.first { it.value == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
val inAppPaymentSubscriberTable: InAppPaymentSubscriberTable = InAppPaymentSubscriberTable(context, this)
|
||||
val chatFoldersTable: ChatFolderTables = ChatFolderTables(context, this)
|
||||
val backupMediaSnapshotTable: BackupMediaSnapshotTable = BackupMediaSnapshotTable(context, this)
|
||||
val pollTable: PollTables = PollTables(context, this)
|
||||
|
||||
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
@@ -147,6 +148,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
executeStatements(db, NotificationProfileTables.CREATE_TABLE)
|
||||
executeStatements(db, DistributionListTables.CREATE_TABLE)
|
||||
executeStatements(db, ChatFolderTables.CREATE_TABLE)
|
||||
executeStatements(db, PollTables.CREATE_TABLE)
|
||||
db.execSQL(BackupMediaSnapshotTable.CREATE_TABLE)
|
||||
|
||||
executeStatements(db, RecipientTable.CREATE_INDEXS)
|
||||
@@ -172,6 +174,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
executeStatements(db, ChatFolderTables.CREATE_INDEXES)
|
||||
executeStatements(db, NameCollisionTables.CREATE_INDEXES)
|
||||
executeStatements(db, BackupMediaSnapshotTable.CREATE_INDEXES)
|
||||
executeStatements(db, PollTables.CREATE_INDEXES)
|
||||
|
||||
executeStatements(db, MessageSendLogTables.CREATE_TRIGGERS)
|
||||
|
||||
@@ -582,5 +585,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
@get:JvmName("backupMediaSnapshots")
|
||||
val backupMediaSnapshots: BackupMediaSnapshotTable
|
||||
get() = instance!!.backupMediaSnapshotTable
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("polls")
|
||||
val polls: PollTables
|
||||
get() = instance!!.pollTable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ public final class ThreadBodyUtil {
|
||||
return new ThreadBody(getCallLogSummary(context, record));
|
||||
} else if (MessageRecordUtil.isScheduled(record)) {
|
||||
return new ThreadBody(context.getString(R.string.ThreadRecord_scheduled_message));
|
||||
} else if (MessageRecordUtil.hasPoll(record)) {
|
||||
return new ThreadBody(context.getString(R.string.Poll__poll_question, record.getPoll().getQuestion()));
|
||||
} else if (MessageRecordUtil.hasPollTerminate(record)) {
|
||||
String creator = record.isOutgoing() ? context.getResources().getString(R.string.MessageRecord_you) : record.getFromRecipient().getDisplayName(context);
|
||||
return new ThreadBody(context.getString(R.string.Poll__poll_end, creator, record.getMessageExtras().pollTerminate.question));
|
||||
}
|
||||
|
||||
boolean hasImage = false;
|
||||
@@ -96,6 +101,14 @@ public final class ThreadBodyUtil {
|
||||
}
|
||||
}
|
||||
|
||||
public static CharSequence getFormattedBodyForPollNotification(@NonNull Context context, @NonNull MmsMessageRecord record) {
|
||||
return format(EmojiStrings.POLL, context.getString(R.string.Poll__poll_question, record.getPoll().getQuestion()), null).body;
|
||||
}
|
||||
|
||||
public static CharSequence getFormattedBodyForPollEndNotification(@NonNull Context context, @NonNull MmsMessageRecord record) {
|
||||
return format(EmojiStrings.POLL, context.getString(R.string.Poll__poll_end, record.getFromRecipient().getDisplayName(context), record.getMessageExtras().pollTerminate.question), null).body;
|
||||
}
|
||||
|
||||
private static @NonNull String getGiftSummary(@NonNull Context context, @NonNull MessageRecord messageRecord) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
return context.getString(R.string.ThreadRecord__you_donated_for_s, messageRecord.getToRecipient().getShortDisplayName(context));
|
||||
|
||||
@@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.JsonUtils.SaneJSONObject
|
||||
import org.thoughtcrime.securesms.util.LRUCache
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.isPoll
|
||||
import org.thoughtcrime.securesms.util.isScheduled
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.storage.SignalAccountRecord
|
||||
@@ -496,6 +497,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
}
|
||||
|
||||
messages.setAllReactionsSeen()
|
||||
messages.setAllVotesSeen()
|
||||
notifyConversationListListeners()
|
||||
|
||||
return messageRecords
|
||||
@@ -557,6 +559,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
messageRecords += messages.setMessagesReadSince(threadId, sinceTimestamp)
|
||||
|
||||
messages.setReactionsSeen(threadId, sinceTimestamp)
|
||||
messages.setVoteSeen(threadId, sinceTimestamp)
|
||||
|
||||
val unreadCount = messages.getUnreadCount(threadId)
|
||||
val unreadMentionsCount = messages.getUnreadMentionCount(threadId)
|
||||
@@ -2097,6 +2100,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
Extra.forSticker(slide.emoji, authorId)
|
||||
} else if (record.isMms && (record as MmsMessageRecord).slideDeck.slides.size > 1) {
|
||||
Extra.forAlbum(authorId)
|
||||
} else if (record.isPoll()) {
|
||||
Extra.forPoll(authorId)
|
||||
} else if (threadRecipient != null && threadRecipient.isGroup) {
|
||||
Extra.forDefault(authorId)
|
||||
} else {
|
||||
@@ -2280,7 +2285,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
individualRecipientId = jsonObject.getString("individualRecipientId")!!,
|
||||
bodyRanges = jsonObject.getString("bodyRanges"),
|
||||
isScheduled = jsonObject.getBoolean("isScheduled"),
|
||||
isRecipientHidden = jsonObject.getBoolean("isRecipientHidden")
|
||||
isRecipientHidden = jsonObject.getBoolean("isRecipientHidden"),
|
||||
isPoll = jsonObject.getBoolean("isPoll")
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
null
|
||||
@@ -2291,7 +2297,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
|
||||
return ThreadRecord.Builder(cursor.requireLong(ID))
|
||||
.setRecipient(recipient)
|
||||
.setType(cursor.requireInt(SNIPPET_TYPE).toLong())
|
||||
.setType(cursor.requireLong(SNIPPET_TYPE))
|
||||
.setDistributionType(cursor.requireInt(TYPE))
|
||||
.setBody(cursor.requireString(SNIPPET) ?: "")
|
||||
.setDate(cursor.requireLong(DATE))
|
||||
@@ -2367,7 +2373,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
val isScheduled: Boolean = false,
|
||||
@field:JsonProperty
|
||||
@param:JsonProperty("isRecipientHidden")
|
||||
val isRecipientHidden: Boolean = false
|
||||
val isRecipientHidden: Boolean = false,
|
||||
@field:JsonProperty
|
||||
@param:JsonProperty("isPoll")
|
||||
val isPoll: Boolean = false
|
||||
) {
|
||||
|
||||
fun getIndividualRecipientId(): String {
|
||||
@@ -2414,6 +2423,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
fun forScheduledMessage(individualRecipient: RecipientId): Extra {
|
||||
return Extra(individualRecipientId = individualRecipient.serialize(), isScheduled = true)
|
||||
}
|
||||
|
||||
fun forPoll(individualRecipient: RecipientId): Extra {
|
||||
return Extra(individualRecipientId = individualRecipient.serialize(), isPoll = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V288_CopyStickerDat
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V289_AddQuoteTargetContentTypeColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V290_AddArchiveThumbnailTransferStateColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V291_NullOutRemoteKeyIfEmpty
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V292_AddPollTables
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
|
||||
|
||||
/**
|
||||
@@ -297,10 +298,11 @@ object SignalDatabaseMigrations {
|
||||
288 to V288_CopyStickerDataHashStartToEnd,
|
||||
289 to V289_AddQuoteTargetContentTypeColumn,
|
||||
290 to V290_AddArchiveThumbnailTransferStateColumn,
|
||||
291 to V291_NullOutRemoteKeyIfEmpty
|
||||
291 to V291_NullOutRemoteKeyIfEmpty,
|
||||
292 to V292_AddPollTables
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 291
|
||||
const val DATABASE_VERSION = 292
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Adds the tables and indexes necessary for polls
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V292_AddPollTables : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE poll (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
|
||||
message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,
|
||||
question TEXT,
|
||||
allow_multiple_votes INTEGER DEFAULT 0,
|
||||
end_message_id INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE poll_option (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES poll (_id) ON DELETE CASCADE,
|
||||
option_text TEXT,
|
||||
option_order INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE poll_vote (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES poll (_id) ON DELETE CASCADE,
|
||||
poll_option_id INTEGER DEFAULT NULL REFERENCES poll_option (_id) ON DELETE CASCADE,
|
||||
voter_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
|
||||
vote_count INTEGER,
|
||||
date_received INTEGER DEFAULT 0,
|
||||
vote_state INTEGER DEFAULT 0,
|
||||
UNIQUE(poll_id, voter_id, poll_option_id) ON CONFLICT REPLACE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL("CREATE INDEX poll_author_id_index ON poll (author_id)")
|
||||
db.execSQL("CREATE INDEX poll_message_id_index ON poll (message_id)")
|
||||
|
||||
db.execSQL("CREATE INDEX poll_option_poll_id_index ON poll_option (poll_id)")
|
||||
|
||||
db.execSQL("CREATE INDEX poll_vote_poll_id_index ON poll_vote (poll_id)")
|
||||
db.execSQL("CREATE INDEX poll_vote_poll_option_id_index ON poll_vote (poll_option_id)")
|
||||
db.execSQL("CREATE INDEX poll_vote_voter_id_index ON poll_vote (voter_id)")
|
||||
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN votes_unread INTEGER DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN votes_last_seen INTEGER DEFAULT 0")
|
||||
db.execSQL("CREATE INDEX message_votes_unread_index ON message (votes_unread)")
|
||||
}
|
||||
}
|
||||
@@ -260,4 +260,8 @@ public abstract class DisplayRecord {
|
||||
public boolean isUnsupported() {
|
||||
return MessageTypes.isUnsupportedMessageType(type);
|
||||
}
|
||||
|
||||
public boolean isPollTerminate() {
|
||||
return MessageTypes.isPollTerminate(type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
@@ -293,6 +294,9 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return staticUpdateDescription(context.getString(isGroupV2() ? R.string.MessageRecord_you_unblocked_this_group : R.string.MessageRecord_you_unblocked_this_person) , Glyph.THREAD);
|
||||
} else if (isUnsupported()) {
|
||||
return staticUpdateDescription(context.getString(R.string.MessageRecord_unsupported_feature, getFromRecipient().getDisplayName(context)), Glyph.ERROR);
|
||||
} else if (MessageRecordUtil.hasPollTerminate(this)) {
|
||||
String creator = isOutgoing() ? context.getString(R.string.MessageRecord_you) : getFromRecipient().getDisplayName(context);
|
||||
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_ended_the_poll, creator, messageExtras.pollTerminate.question), Glyph.POLL);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -476,6 +480,10 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return UpdateDescription.staticDescription(string, glyph);
|
||||
}
|
||||
|
||||
protected static @NonNull UpdateDescription staticUpdateDescriptionWithExpiration(@NonNull String string, Glyph glyph) {
|
||||
return UpdateDescription.staticDescriptionWithExpiration(string, glyph);
|
||||
}
|
||||
|
||||
protected static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string,
|
||||
Glyph glyph,
|
||||
@ColorInt int lightTint,
|
||||
@@ -732,7 +740,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() ||
|
||||
isChangeNumber() || isReleaseChannelDonationRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() ||
|
||||
isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted() ||
|
||||
isBlocked() || isUnblocked() || isUnsupported();
|
||||
isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate();
|
||||
}
|
||||
|
||||
public boolean isMediaPending() {
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.database.model
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.payments.Payment
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
|
||||
fun MessageRecord.withReactions(reactions: List<ReactionRecord>): MessageRecord {
|
||||
return if (this is MmsMessageRecord) {
|
||||
@@ -39,3 +40,11 @@ fun MessageRecord.withCall(call: CallTable.Call): MessageRecord {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
fun MessageRecord.withPoll(poll: PollRecord): MessageRecord {
|
||||
return if (this is MmsMessageRecord) {
|
||||
this.withPoll(poll)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,22 +25,20 @@ import org.thoughtcrime.securesms.database.MessageTypes;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.payments.CryptoValueUtil;
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -75,6 +73,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
private final BodyRangeList messageRanges;
|
||||
private final Payment payment;
|
||||
private final CallTable.Call call;
|
||||
private final PollRecord poll;
|
||||
private final long scheduledDate;
|
||||
private final MessageId latestRevisionId;
|
||||
private final boolean isRead;
|
||||
@@ -115,6 +114,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
@Nullable GiftBadge giftBadge,
|
||||
@Nullable Payment payment,
|
||||
@Nullable CallTable.Call call,
|
||||
@Nullable PollRecord poll,
|
||||
long scheduledDate,
|
||||
@Nullable MessageId latestRevisionId,
|
||||
@Nullable MessageId originalMessageId,
|
||||
@@ -137,6 +137,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
this.messageRanges = messageRanges;
|
||||
this.payment = payment;
|
||||
this.call = call;
|
||||
this.poll = poll;
|
||||
this.scheduledDate = scheduledDate;
|
||||
this.latestRevisionId = latestRevisionId;
|
||||
this.isRead = isRead;
|
||||
@@ -199,6 +200,10 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return giftBadge;
|
||||
}
|
||||
|
||||
public @Nullable PollRecord getPoll() {
|
||||
return poll;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSelfMention() {
|
||||
return mentionsSelf;
|
||||
@@ -332,7 +337,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -340,7 +345,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -362,7 +367,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), slideDeck,
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -370,7 +375,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -379,7 +384,15 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,11 @@ public final class ThreadRecord {
|
||||
else return true;
|
||||
}
|
||||
|
||||
public boolean isPoll() {
|
||||
if (extra != null) return extra.isPoll();
|
||||
else return false;
|
||||
}
|
||||
|
||||
public boolean isPinned() {
|
||||
return isPinned;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@ import android.text.SpannableStringBuilder;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
|
||||
@@ -35,6 +33,7 @@ public final class UpdateDescription {
|
||||
private final SpannableFactory stringFactory;
|
||||
private final Spannable staticString;
|
||||
private final Glyph glyph;
|
||||
private final boolean canExpire;
|
||||
private final int lightTint;
|
||||
private final int darkTint;
|
||||
|
||||
@@ -43,6 +42,16 @@ public final class UpdateDescription {
|
||||
@Nullable Spannable staticString,
|
||||
@NonNull Glyph glyph,
|
||||
@ColorInt int lightTint,
|
||||
@ColorInt int darkTint) {
|
||||
this(mentioned, stringFactory, staticString, glyph, false, lightTint, darkTint);
|
||||
}
|
||||
|
||||
private UpdateDescription(@NonNull Collection<ServiceId> mentioned,
|
||||
@Nullable SpannableFactory stringFactory,
|
||||
@Nullable Spannable staticString,
|
||||
@NonNull Glyph glyph,
|
||||
boolean canExpire,
|
||||
@ColorInt int lightTint,
|
||||
@ColorInt int darkTint)
|
||||
{
|
||||
if (staticString == null && stringFactory == null) {
|
||||
@@ -52,6 +61,7 @@ public final class UpdateDescription {
|
||||
this.stringFactory = stringFactory;
|
||||
this.staticString = staticString;
|
||||
this.glyph = glyph;
|
||||
this.canExpire = canExpire;
|
||||
this.lightTint = lightTint;
|
||||
this.darkTint = darkTint;
|
||||
}
|
||||
@@ -84,6 +94,13 @@ public final class UpdateDescription {
|
||||
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update description that's string value is fixed with a start glyph and has the ability to expire when a disappearing timer is set.
|
||||
*/
|
||||
public static UpdateDescription staticDescriptionWithExpiration(@NonNull String staticString, Glyph glyph) {
|
||||
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, true,0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update description that's string value is fixed.
|
||||
*/
|
||||
@@ -144,6 +161,10 @@ public final class UpdateDescription {
|
||||
return darkTint;
|
||||
}
|
||||
|
||||
public boolean hasExpiration() {
|
||||
return canExpire;
|
||||
}
|
||||
|
||||
public static UpdateDescription concatWithNewLines(@NonNull List<UpdateDescription> updateDescriptions) {
|
||||
if (updateDescriptions.size() == 0) {
|
||||
throw new AssertionError();
|
||||
|
||||
Reference in New Issue
Block a user