Release polls behind feature flag.

This commit is contained in:
Michelle Tang
2025-10-01 12:46:37 -04:00
parent 67a693107e
commit b8e4ffb5ae
84 changed files with 4164 additions and 102 deletions

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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 }
}
}
}

View File

@@ -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
}
}

View File

@@ -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));

View File

@@ -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)
}
}
}

View File

@@ -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) {

View File

@@ -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)")
}
}

View File

@@ -260,4 +260,8 @@ public abstract class DisplayRecord {
public boolean isUnsupported() {
return MessageTypes.isUnsupportedMessageType(type);
}
public boolean isPollTerminate() {
return MessageTypes.isPollTerminate(type);
}
}

View File

@@ -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() {

View File

@@ -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
}
}

View File

@@ -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());
}

View File

@@ -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;
}

View File

@@ -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();