Merge tag 'v6.19.8' into molly-6.19

This commit is contained in:
Oscar Mira 2023-05-08 20:19:54 +02:00
commit ce72ede47f
383 changed files with 15105 additions and 4836 deletions

View file

@ -41,13 +41,6 @@
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="INDENT_SIZE" value="2" />

View file

@ -56,8 +56,8 @@ ext {
MAPS_API_KEY = getEnv('CI_MAPS_API_KEY') ?: mapsApiKey
}
def canonicalVersionCode = 1247
def canonicalVersionName = "6.18.4"
def canonicalVersionCode = 1256
def canonicalVersionName = "6.19.8"
def mollyRevision = 0
def postFixSize = 100
@ -150,7 +150,8 @@ android {
jniLibs {
// MOLLY: Compress native libs by default as APK is not split on ABIs
useLegacyPackaging true
} }
}
}
buildFeatures {
@ -390,7 +391,6 @@ tasks.withType(JavaCompile) {
}
dependencies {
implementation libs.androidx.core.ktx
implementation libs.androidx.fragment.ktx
lintChecks project(':lintchecks')
@ -551,6 +551,8 @@ dependencies {
testImplementation testLibs.espresso.core
implementation libs.kotlin.stdlib.jdk8
implementation libs.rxjava3.rxandroid
implementation libs.rxjava3.rxkotlin
implementation libs.rxdogtag

View file

@ -8,6 +8,10 @@ import net.zetetic.database.sqlcipher.SQLiteOpenHelper
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ForeignKeyConstraint
import org.signal.core.util.Index
import org.signal.core.util.getForeignKeys
import org.signal.core.util.getIndexes
import org.signal.core.util.readToList
import org.signal.core.util.requireNonNullString
import org.thoughtcrime.securesms.database.helpers.SignalDatabaseMigrations
@ -24,7 +28,7 @@ class DatabaseConsistencyTest {
val harness = SignalActivityRule()
@Test
fun test() {
fun testUpgradeConsistency() {
val currentVersionStatements = SignalDatabase.rawDatabase.getAllCreateStatements()
val testHelper = InMemoryTestHelper(ApplicationDependencies.getApplication()).also {
it.onUpgrade(it.writableDatabase, 181, SignalDatabaseMigrations.DATABASE_VERSION)
@ -61,6 +65,30 @@ class DatabaseConsistencyTest {
}
}
@Test
fun testForeignKeyIndexCoverage() {
/** We may deem certain indexes non-critical if deletion frequency is low or table size is small. */
val ignoredColumns: List<Pair<String, String>> = listOf(
StorySendTable.TABLE_NAME to StorySendTable.DISTRIBUTION_ID
)
val foreignKeys: List<ForeignKeyConstraint> = SignalDatabase.rawDatabase.getForeignKeys()
val indexesByFirstColumn: List<Index> = SignalDatabase.rawDatabase.getIndexes()
val notFound: List<Pair<String, String>> = foreignKeys
.filterNot { ignoredColumns.contains(it.table to it.column) }
.filterNot { foreignKey ->
indexesByFirstColumn.hasPrimaryIndexFor(foreignKey.table, foreignKey.column)
}
.map { it.table to it.column }
assertTrue("Missing indexes to cover: $notFound", notFound.isEmpty())
}
private fun List<Index>.hasPrimaryIndexFor(table: String, column: String): Boolean {
return this.any { index -> index.table == table && index.columns[0] == column }
}
private data class Statement(
val name: String,
val sql: String
@ -74,6 +102,7 @@ class DatabaseConsistencyTest {
sql = cursor.requireNonNullString("sql").normalizeSql()
)
}
.filterNot { it.name.startsWith("sqlite_stat") }
.sortedBy { it.name }
}
@ -82,9 +111,10 @@ class DatabaseConsistencyTest {
.split("\n")
.map { it.trim() }
.joinToString(separator = " ")
.replace(Regex("\\s+"), " ")
.replace(Regex.fromLiteral("( "), "(")
.replace(Regex.fromLiteral(" )"), ")")
.replace(Regex.fromLiteral("CREATE TABLE \"call\""), "CREATE TABLE call") // solves a specific weirdness with inconsequential quotes
.replace(Regex("CREATE TABLE \"([a-z]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
}
private class InMemoryTestHelper(private val application: Application) : SQLiteOpenHelper(application, null, null, 1) {

View file

@ -693,9 +693,9 @@ class RecipientTableTest_getAndPossiblyMerge {
val sms2: MessageRecord = SignalDatabase.messages.getMessageRecord(smsId2)!!
val sms3: MessageRecord = SignalDatabase.messages.getMessageRecord(smsId3)!!
assertEquals(retrievedId, sms1.recipient.id)
assertEquals(retrievedId, sms2.recipient.id)
assertEquals(retrievedId, sms3.recipient.id)
assertEquals(retrievedId, sms1.fromRecipient.id)
assertEquals(retrievedId, sms2.fromRecipient.id)
assertEquals(retrievedId, sms3.fromRecipient.id)
assertEquals(retrievedThreadId, sms1.threadId)
assertEquals(retrievedThreadId, sms2.threadId)
@ -706,9 +706,9 @@ class RecipientTableTest_getAndPossiblyMerge {
val mms2: MessageRecord = SignalDatabase.messages.getMessageRecord(mmsId2)!!
val mms3: MessageRecord = SignalDatabase.messages.getMessageRecord(mmsId3)!!
assertEquals(retrievedId, mms1.recipient.id)
assertEquals(retrievedId, mms2.recipient.id)
assertEquals(retrievedId, mms3.recipient.id)
assertEquals(retrievedId, mms1.fromRecipient.id)
assertEquals(retrievedId, mms2.fromRecipient.id)
assertEquals(retrievedId, mms3.fromRecipient.id)
assertEquals(retrievedThreadId, mms1.threadId)
assertEquals(retrievedThreadId, mms2.threadId)
@ -1035,7 +1035,7 @@ class RecipientTableTest_getAndPossiblyMerge {
return SignalDatabase.rawDatabase
.select(MessageTable.BODY)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.THREAD_MERGE_TYPE)
.where("${MessageTable.FROM_RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.THREAD_MERGE_TYPE)
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
.limit(1)
.run()
@ -1053,7 +1053,7 @@ class RecipientTableTest_getAndPossiblyMerge {
return SignalDatabase.rawDatabase
.select(MessageTable.BODY)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.SESSION_SWITCHOVER_TYPE)
.where("${MessageTable.FROM_RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.SESSION_SWITCHOVER_TYPE)
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
.limit(1)
.run()

View file

@ -0,0 +1,250 @@
package org.thoughtcrime.securesms.messages
import android.database.Cursor
import android.util.Base64
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.signal.core.util.readToList
import org.signal.core.util.select
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.model.toBodyRangeList
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableUtils
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.EditMessage
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
class EditMessageSyncProcessorTest {
companion object {
private val IGNORE_MESSAGE_COLUMNS = listOf(
MessageTable.DATE_RECEIVED,
MessageTable.NOTIFIED_TIMESTAMP,
MessageTable.REACTIONS_LAST_SEEN,
MessageTable.NOTIFIED
)
private val IGNORE_ATTACHMENT_COLUMNS = listOf(
AttachmentTable.UNIQUE_ID,
AttachmentTable.TRANSFER_FILE
)
}
@get:Rule
val harness = SignalActivityRule()
private lateinit var processorV2: MessageContentProcessorV2
private lateinit var testResult: TestResults
private var envelopeTimestamp: Long = 0
@Before
fun setup() {
processorV2 = MessageContentProcessorV2(harness.context)
envelopeTimestamp = System.currentTimeMillis()
testResult = TestResults()
}
@Test
fun textMessage() {
var originalTimestamp = envelopeTimestamp + 200
for (i in 1..10) {
originalTimestamp += 400
val toRecipient = Recipient.resolved(harness.others[0])
val content = MessageContentFuzzer.fuzzTextMessage()
val metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, toRecipient.id)
val syncContent = SignalServiceProtos.Content.newBuilder().setSyncMessage(
SignalServiceProtos.SyncMessage.newBuilder().setSent(
SignalServiceProtos.SyncMessage.Sent.newBuilder()
.setDestinationUuid(metadata.destinationServiceId.toString())
.setTimestamp(originalTimestamp)
.setExpirationStartTimestamp(originalTimestamp)
.setMessage(content.dataMessage)
)
).build()
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage.expireTimer)
val syncTextMessage = TestMessage(
envelope = MessageContentFuzzer.envelope(originalTimestamp),
content = syncContent,
metadata = metadata,
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(originalTimestamp)
)
val editTimestamp = originalTimestamp + 200
val editedContent = MessageContentFuzzer.fuzzTextMessage()
val editSyncContent = SignalServiceProtos.Content.newBuilder().setSyncMessage(
SignalServiceProtos.SyncMessage.newBuilder().setSent(
SignalServiceProtos.SyncMessage.Sent.newBuilder()
.setDestinationUuid(metadata.destinationServiceId.toString())
.setTimestamp(editTimestamp)
.setExpirationStartTimestamp(editTimestamp)
.setEditMessage(
EditMessage.newBuilder()
.setDataMessage(editedContent.dataMessage)
.setTargetSentTimestamp(originalTimestamp)
)
)
).build()
val syncEditMessage = TestMessage(
envelope = MessageContentFuzzer.envelope(editTimestamp),
content = editSyncContent,
metadata = metadata,
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(editTimestamp)
)
testResult.runSync(listOf(syncTextMessage, syncEditMessage))
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage.expireTimer / 1000)
val originalTextMessage = OutgoingMessage(
threadRecipient = toRecipient,
sentTimeMillis = originalTimestamp,
body = content.dataMessage.body,
expiresIn = content.dataMessage.expireTimer.seconds.inWholeMilliseconds,
isUrgent = true,
isSecure = true,
bodyRanges = content.dataMessage.bodyRangesList.toBodyRangeList()
)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(toRecipient)
val originalMessageId = SignalDatabase.messages.insertMessageOutbox(originalTextMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(originalMessageId, true)
if (content.dataMessage.expireTimer > 0) {
SignalDatabase.messages.markExpireStarted(originalMessageId, originalTimestamp)
}
val editMessage = OutgoingMessage(
threadRecipient = toRecipient,
sentTimeMillis = editTimestamp,
body = editedContent.dataMessage.body,
expiresIn = content.dataMessage.expireTimer.seconds.inWholeMilliseconds,
isUrgent = true,
isSecure = true,
bodyRanges = editedContent.dataMessage.bodyRangesList.toBodyRangeList(),
messageToEdit = originalMessageId
)
val editMessageId = SignalDatabase.messages.insertMessageOutbox(editMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(editMessageId, true)
if (content.dataMessage.expireTimer > 0) {
SignalDatabase.messages.markExpireStarted(editMessageId, originalTimestamp)
}
testResult.collectLocal()
testResult.assert()
}
}
private inner class TestResults {
private lateinit var localMessages: List<List<Pair<String, String?>>>
private lateinit var localAttachments: List<List<Pair<String, String?>>>
private lateinit var syncMessages: List<List<Pair<String, String?>>>
private lateinit var syncAttachments: List<List<Pair<String, String?>>>
fun collectLocal() {
harness.inMemoryLogger.clear()
localMessages = dumpMessages()
localAttachments = dumpAttachments()
cleanup()
}
fun runSync(messages: List<TestMessage>) {
messages.forEach { (envelope, content, metadata, serverDeliveredTimestamp) ->
if (content.hasSyncMessage()) {
processorV2.process(
envelope,
content,
metadata,
serverDeliveredTimestamp,
false
)
ThreadUtil.sleep(1)
}
}
harness.inMemoryLogger.clear()
syncMessages = dumpMessages()
syncAttachments = dumpAttachments()
cleanup()
}
fun cleanup() {
SignalDatabase.rawDatabase.withinTransaction { db ->
SignalDatabase.threads.deleteAllConversations()
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${MessageTable.TABLE_NAME}'")
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${ThreadTable.TABLE_NAME}'")
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${AttachmentTable.TABLE_NAME}'")
}
}
fun assert() {
syncMessages.zip(localMessages)
.forEach { (v2, v1) ->
v2.assertIs(v1)
}
syncAttachments.zip(localAttachments)
.forEach { (v2, v1) ->
v2.assertIs(v1)
}
}
private fun dumpMessages(): List<List<Pair<String, String?>>> {
return dumpTable(MessageTable.TABLE_NAME)
.map { row ->
val newRow = row.toMutableList()
newRow.removeIf { IGNORE_MESSAGE_COLUMNS.contains(it.first) }
newRow
}
}
private fun dumpAttachments(): List<List<Pair<String, String?>>> {
return dumpTable(AttachmentTable.TABLE_NAME)
.map { row ->
val newRow = row.toMutableList()
newRow.removeIf { IGNORE_ATTACHMENT_COLUMNS.contains(it.first) }
newRow
}
}
private fun dumpTable(table: String): List<List<Pair<String, String?>>> {
return SignalDatabase.rawDatabase
.select()
.from(table)
.run()
.readToList { cursor ->
val map: List<Pair<String, String?>> = cursor.columnNames.map { column ->
val index = cursor.getColumnIndex(column)
var data: String? = when (cursor.getType(index)) {
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0)
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
}
column to data
}
map
}
}
}
}

View file

@ -10,12 +10,9 @@ import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.signal.core.util.readToList
import org.signal.core.util.select
import org.signal.core.util.toSingleLine
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.MessageTypes.isOutgoingMessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -24,6 +21,7 @@ import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableUtils
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata
@ -280,7 +278,7 @@ class MessageContentProcessorTestV2 {
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = typeColumnToString(cursor.getLong(index))
data = MessageTableUtils.typeColumnToString(cursor.getLong(index))
}
column to data
@ -312,64 +310,4 @@ class MessageContentProcessorTestV2 {
return SignalServiceContent.createFromProto(contentProto)!!
}
fun typeColumnToString(type: Long): String {
return """
isOutgoingMessageType:${isOutgoingMessageType(type)}
isForcedSms:${type and MessageTypes.MESSAGE_FORCE_SMS_BIT != 0L}
isDraftMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_DRAFT_TYPE}
isFailedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_FAILED_TYPE}
isPendingMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_OUTBOX_TYPE || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENDING_TYPE}
isSentType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_TYPE}
isPendingSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
isPendingSecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
isPendingInsecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK}
isInboxType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_INBOX_TYPE}
isJoinedType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.JOINED_TYPE}
isUnsupportedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.UNSUPPORTED_MESSAGE_TYPE}
isInvalidMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.INVALID_MESSAGE_TYPE}
isBadDecryptType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BAD_DECRYPT_TYPE}
isSecureType:${type and MessageTypes.SECURE_MESSAGE_BIT != 0L}
isPushType:${type and MessageTypes.PUSH_MESSAGE_BIT != 0L}
isEndSessionType:${type and MessageTypes.END_SESSION_BIT != 0L}
isKeyExchangeType:${type and MessageTypes.KEY_EXCHANGE_BIT != 0L}
isIdentityVerified:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT != 0L}
isIdentityDefault:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT != 0L}
isCorruptedKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CORRUPTED_BIT != 0L}
isInvalidVersionKeyExchange:${type and MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT != 0L}
isBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_BUNDLE_BIT != 0L}
isContentBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT != 0L}
isIdentityUpdate:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT != 0L}
isRateLimited:${type and MessageTypes.MESSAGE_RATE_LIMITED_BIT != 0L}
isExpirationTimerUpdate:${type and MessageTypes.EXPIRATION_TIMER_UPDATE_BIT != 0L}
isIncomingAudioCall:${type == MessageTypes.INCOMING_AUDIO_CALL_TYPE}
isIncomingVideoCall:${type == MessageTypes.INCOMING_VIDEO_CALL_TYPE}
isOutgoingAudioCall:${type == MessageTypes.OUTGOING_AUDIO_CALL_TYPE}
isOutgoingVideoCall:${type == MessageTypes.OUTGOING_VIDEO_CALL_TYPE}
isMissedAudioCall:${type == MessageTypes.MISSED_AUDIO_CALL_TYPE}
isMissedVideoCall:${type == MessageTypes.MISSED_VIDEO_CALL_TYPE}
isGroupCall:${type == MessageTypes.GROUP_CALL_TYPE}
isGroupUpdate:${type and MessageTypes.GROUP_UPDATE_BIT != 0L}
isGroupV2:${type and MessageTypes.GROUP_V2_BIT != 0L}
isGroupQuit:${type and MessageTypes.GROUP_LEAVE_BIT != 0L && type and MessageTypes.GROUP_V2_BIT == 0L}
isChatSessionRefresh:${type and MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT != 0L}
isDuplicateMessageType:${type and MessageTypes.ENCRYPTION_REMOTE_DUPLICATE_BIT != 0L}
isDecryptInProgressType:${type and 0x40000000 != 0L}
isNoRemoteSessionType:${type and MessageTypes.ENCRYPTION_REMOTE_NO_SESSION_BIT != 0L}
isLegacyType:${type and MessageTypes.ENCRYPTION_REMOTE_LEGACY_BIT != 0L || type and MessageTypes.ENCRYPTION_REMOTE_BIT != 0L}
isProfileChange:${type == MessageTypes.PROFILE_CHANGE_TYPE}
isGroupV1MigrationEvent:${type == MessageTypes.GV1_MIGRATION_TYPE}
isChangeNumber:${type == MessageTypes.CHANGE_NUMBER_TYPE}
isBoostRequest:${type == MessageTypes.BOOST_REQUEST_TYPE}
isThreadMerge:${type == MessageTypes.THREAD_MERGE_TYPE}
isSmsExport:${type == MessageTypes.SMS_EXPORT_TYPE}
isGroupV2LeaveOnly:${type and MessageTypes.GROUP_V2_LEAVE_BITS == MessageTypes.GROUP_V2_LEAVE_BITS}
isSpecialType:${type and MessageTypes.SPECIAL_TYPES_MASK != 0L}
isStoryReaction:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_STORY_REACTION}
isGiftBadge:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_GIFT_BADGE}
isPaymentsNotificaiton:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION}
isRequestToActivatePayments:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST}
isPaymentsActivated:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED}
""".trimIndent().replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").toSingleLine()
}
}

View file

@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.util
import org.thoughtcrime.securesms.database.MessageTypes
object MessageTableUtils {
fun typeColumnToString(type: Long): String {
return """
isOutgoingMessageType:${MessageTypes.isOutgoingMessageType(type)}
isForcedSms:${type and MessageTypes.MESSAGE_FORCE_SMS_BIT != 0L}
isDraftMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_DRAFT_TYPE}
isFailedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_FAILED_TYPE}
isPendingMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_OUTBOX_TYPE || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENDING_TYPE}
isSentType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_TYPE}
isPendingSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
isPendingSecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
isPendingInsecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK}
isInboxType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_INBOX_TYPE}
isJoinedType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.JOINED_TYPE}
isUnsupportedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.UNSUPPORTED_MESSAGE_TYPE}
isInvalidMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.INVALID_MESSAGE_TYPE}
isBadDecryptType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BAD_DECRYPT_TYPE}
isSecureType:${type and MessageTypes.SECURE_MESSAGE_BIT != 0L}
isPushType:${type and MessageTypes.PUSH_MESSAGE_BIT != 0L}
isEndSessionType:${type and MessageTypes.END_SESSION_BIT != 0L}
isKeyExchangeType:${type and MessageTypes.KEY_EXCHANGE_BIT != 0L}
isIdentityVerified:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT != 0L}
isIdentityDefault:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT != 0L}
isCorruptedKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CORRUPTED_BIT != 0L}
isInvalidVersionKeyExchange:${type and MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT != 0L}
isBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_BUNDLE_BIT != 0L}
isContentBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT != 0L}
isIdentityUpdate:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT != 0L}
isRateLimited:${type and MessageTypes.MESSAGE_RATE_LIMITED_BIT != 0L}
isExpirationTimerUpdate:${type and MessageTypes.EXPIRATION_TIMER_UPDATE_BIT != 0L}
isIncomingAudioCall:${type == MessageTypes.INCOMING_AUDIO_CALL_TYPE}
isIncomingVideoCall:${type == MessageTypes.INCOMING_VIDEO_CALL_TYPE}
isOutgoingAudioCall:${type == MessageTypes.OUTGOING_AUDIO_CALL_TYPE}
isOutgoingVideoCall:${type == MessageTypes.OUTGOING_VIDEO_CALL_TYPE}
isMissedAudioCall:${type == MessageTypes.MISSED_AUDIO_CALL_TYPE}
isMissedVideoCall:${type == MessageTypes.MISSED_VIDEO_CALL_TYPE}
isGroupCall:${type == MessageTypes.GROUP_CALL_TYPE}
isGroupUpdate:${type and MessageTypes.GROUP_UPDATE_BIT != 0L}
isGroupV2:${type and MessageTypes.GROUP_V2_BIT != 0L}
isGroupQuit:${type and MessageTypes.GROUP_LEAVE_BIT != 0L && type and MessageTypes.GROUP_V2_BIT == 0L}
isChatSessionRefresh:${type and MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT != 0L}
isDuplicateMessageType:${type and MessageTypes.ENCRYPTION_REMOTE_DUPLICATE_BIT != 0L}
isDecryptInProgressType:${type and 0x40000000 != 0L}
isNoRemoteSessionType:${type and MessageTypes.ENCRYPTION_REMOTE_NO_SESSION_BIT != 0L}
isLegacyType:${type and MessageTypes.ENCRYPTION_REMOTE_LEGACY_BIT != 0L || type and MessageTypes.ENCRYPTION_REMOTE_BIT != 0L}
isProfileChange:${type == MessageTypes.PROFILE_CHANGE_TYPE}
isGroupV1MigrationEvent:${type == MessageTypes.GV1_MIGRATION_TYPE}
isChangeNumber:${type == MessageTypes.CHANGE_NUMBER_TYPE}
isBoostRequest:${type == MessageTypes.BOOST_REQUEST_TYPE}
isThreadMerge:${type == MessageTypes.THREAD_MERGE_TYPE}
isSmsExport:${type == MessageTypes.SMS_EXPORT_TYPE}
isGroupV2LeaveOnly:${type and MessageTypes.GROUP_V2_LEAVE_BITS == MessageTypes.GROUP_V2_LEAVE_BITS}
isSpecialType:${type and MessageTypes.SPECIAL_TYPES_MASK != 0L}
isStoryReaction:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_STORY_REACTION}
isGiftBadge:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_GIFT_BADGE}
isPaymentsNotificaiton:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION}
isRequestToActivatePayments:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST}
isPaymentsActivated:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED}
""".trimIndent().replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").replace("\n", "")
}
}

View file

@ -261,6 +261,16 @@
</intent-filter>
</activity>
<activity android:name=".conversation.v2.ConversationActivity"
android:windowSoftInputMode="stateUnchanged"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:parentActivityName=".MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.MainActivity" />
</activity>
<activity android:name=".conversation.ConversationActivity"
android:windowSoftInputMode="stateUnchanged"
android:launchMode="singleTask"

File diff suppressed because it is too large Load diff

View file

@ -109,10 +109,9 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onActivatePaymentsClicked();
void onSendPaymentClicked(@NonNull RecipientId recipientId);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord);
}
}

View file

@ -36,7 +36,6 @@ import android.view.WindowManager;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.ViewModelProvider;
@ -298,7 +297,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
if (viewModel.canEnterPipMode()) {
try {
enterPictureInPictureMode(pipBuilderParams.build());
} catch (IllegalStateException e) {
} catch (Exception e) {
Log.w(TAG, "Device lied to us about supporting PiP.", e);
return false;
}
@ -390,7 +389,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
pipBuilderParams.setAutoEnterEnabled(true);
}
if (Build.VERSION.SDK_INT >= 26) {
setPictureInPictureParams(pipBuilderParams.build());
try {
setPictureInPictureParams(pipBuilderParams.build());
} catch (Exception e) {
Log.w(TAG, "System lied about having PiP available.", e);
}
}
}
}
@ -478,7 +481,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
.ifNecessary()
.withRationaleDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName), R.drawable.ic_video_solid_24_tinted)
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName))
.onAllGranted(() -> ApplicationDependencies.getSignalCallManager().setMuteVideo(!muted))
.onAllGranted(() -> ApplicationDependencies.getSignalCallManager().setEnableVideo(!muted))
.execute();
}
}

View file

@ -17,7 +17,9 @@ import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.subjects.SingleSubject;
@ -32,8 +34,8 @@ public class AudioRecorder {
private final AudioRecordingHandler uiHandler;
private final AudioRecorderFocusManager audioFocusManager;
private Recorder recorder;
private Uri captureUri;
private Recorder recorder;
private Future<Uri> recordingUriFuture;
private SingleSubject<VoiceNoteDraft> recordingSubject;
@ -75,11 +77,12 @@ public class AudioRecorder {
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
captureUri = BlobProvider.getInstance()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
recordingUriFuture = BlobProvider.getInstance()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context);
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
int focusResult = audioFocusManager.requestAudioFocus();
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
@ -109,16 +112,17 @@ public class AudioRecorder {
recorder.stop();
try {
long size = MediaUtil.getMediaSize(context, captureUri);
recordingSubject.onSuccess(new VoiceNoteDraft(captureUri, size));
} catch (IOException ioe) {
Uri uri = recordingUriFuture.get();
long size = MediaUtil.getMediaSize(context, uri);
recordingSubject.onSuccess(new VoiceNoteDraft(uri, size));
} catch (IOException | ExecutionException | InterruptedException ioe) {
Log.w(TAG, ioe);
recordingSubject.onError(ioe);
}
recordingSubject = null;
recorder = null;
captureUri = null;
recordingSubject = null;
recorder = null;
recordingUriFuture = null;
});
}
}

View file

@ -4,6 +4,7 @@ import android.media.MediaRecorder;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
import java.io.IOException;
@ -20,10 +21,14 @@ public class MediaRecorderWrapper implements Recorder {
private static final int BIT_RATE = 32000;
private MediaRecorder recorder = null;
private ParcelFileDescriptor outputFileDescriptor;
@Override
public void start(ParcelFileDescriptor fileDescriptor) throws IOException {
Log.i(TAG, "Recording voice note using MediaRecorderWrapper.");
this.outputFileDescriptor = fileDescriptor;
recorder = new MediaRecorder();
try {
@ -40,6 +45,8 @@ public class MediaRecorderWrapper implements Recorder {
Log.w(TAG, "Unable to start recording", e);
recorder.release();
recorder = null;
StreamUtil.close(outputFileDescriptor);
outputFileDescriptor = null;
throw new IOException(e);
}
}
@ -61,6 +68,8 @@ public class MediaRecorderWrapper implements Recorder {
} finally {
recorder.release();
recorder = null;
StreamUtil.close(outputFileDescriptor);
outputFileDescriptor = null;
}
}

View file

@ -16,12 +16,12 @@ object BackupCountQueries {
SELECT COUNT(*) FROM ${GroupReceiptTable.TABLE_NAME}
INNER JOIN ${MessageTable.TABLE_NAME} ON ${GroupReceiptTable.TABLE_NAME}.${GroupReceiptTable.MMS_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
WHERE ${MessageTable.TABLE_NAME}.${MessageTable.EXPIRES_IN} <= 0 AND ${MessageTable.TABLE_NAME}.${MessageTable.VIEW_ONCE} <= 0
""".trimIndent()
"""
@get:JvmStatic
val attachmentCount: String = """
SELECT COUNT(*) FROM ${AttachmentTable.TABLE_NAME}
INNER JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MMS_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
WHERE ${MessageTable.TABLE_NAME}.${MessageTable.EXPIRES_IN} <= 0 AND ${MessageTable.TABLE_NAME}.${MessageTable.VIEW_ONCE} <= 0
""".trimIndent()
"""
}

View file

@ -332,7 +332,10 @@ public class FullBackupExporter extends FullBackupBase {
Map<String, Set<String>> dependsOn = new LinkedHashMap<>();
for (String table : tables) {
dependsOn.put(table, SqlUtil.getForeignKeyDependencies(input, table));
Set<String> dependencies = SqlUtil.getForeignKeyDependencies(input, table);
dependencies.remove(table);
dependsOn.put(table, dependencies);
}
for (String table : tables) {
@ -576,7 +579,7 @@ public class FullBackupExporter extends FullBackupBase {
}
private static boolean isForNonExpiringMmsMessage(@NonNull SQLiteDatabase db, long mmsId) {
String[] columns = new String[] { MessageTable.RECIPIENT_ID, MessageTable.EXPIRES_IN, MessageTable.VIEW_ONCE };
String[] columns = new String[] { MessageTable.EXPIRES_IN, MessageTable.VIEW_ONCE };
String where = MessageTable.ID + " = ?";
String[] args = new String[] { String.valueOf(mmsId) };

View file

@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.SecurePreferenceManager;
import org.thoughtcrime.securesms.util.Util;
import java.io.ByteArrayOutputStream;
import java.io.File;
@ -84,6 +85,7 @@ public class FullBackupImporter extends FullBackupBase {
SQLiteDatabase keyValueDatabase = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication()).getSqlCipherDatabase();
db.setForeignKeyConstraintsEnabled(false);
db.beginTransaction();
keyValueDatabase.beginTransaction();
try {
@ -98,7 +100,7 @@ public class FullBackupImporter extends FullBackupBase {
count++;
if (frame.version != null) processVersion(db, frame.version);
else if (frame.statement != null) tryProcessStatement(db, frame.statement);
else if (frame.statement != null) processStatement(db, frame.statement);
else if (frame.preference != null) processPreference(context, frame.preference);
else if (frame.attachment != null) processAttachment(context, attachmentSecret, db, frame.attachment, inputStream);
else if (frame.sticker != null) processSticker(context, attachmentSecret, db, frame.sticker, inputStream);
@ -110,8 +112,20 @@ public class FullBackupImporter extends FullBackupBase {
db.setTransactionSuccessful();
keyValueDatabase.setTransactionSuccessful();
} finally {
List<SqlUtil.ForeignKeyViolation> violations = SqlUtil.getForeignKeyViolations(db)
.stream()
.filter(it -> !it.getTable().startsWith("msl_"))
.collect(Collectors.toList());
if (violations.size() > 0) {
Log.w(TAG, "Foreign key constraints failed!\n" + Util.join(violations, "\n"));
//noinspection ThrowFromFinallyBlock
throw new ForeignKeyViolationException(violations);
}
db.endTransaction();
keyValueDatabase.endTransaction();
db.setForeignKeyConstraintsEnabled(true);
}
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count, 0));
@ -133,31 +147,6 @@ public class FullBackupImporter extends FullBackupBase {
db.setVersion(version.version);
}
private static void tryProcessStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
try {
processStatement(db, statement);
} catch (SQLiteConstraintException e) {
String tableName = "?";
String statementString = statement.statement;
if (statementString != null && statementString.startsWith("INSERT INTO ")) {
int nameStart = "INSERT INTO ".length();
int nameEnd = statementString.indexOf(" ", "INSERT INTO ".length());
if (nameStart < statementString.length() && nameEnd > nameStart) {
tableName = statementString.substring(nameStart, nameEnd);
}
}
if (tableName.startsWith("msl_")) {
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Ignoring.");
} else {
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Throwing!");
throw e;
}
}
}
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
if (statement.statement == null) {
Log.w(TAG, "Null statement!");
@ -342,7 +331,10 @@ public class FullBackupImporter extends FullBackupBase {
Map<String, Set<String>> dependsOn = new LinkedHashMap<>();
for (String table : tables) {
dependsOn.put(table, SqlUtil.getForeignKeyDependencies(input, table));
Set<String> dependencies = SqlUtil.getForeignKeyDependencies(input, table);
dependencies.remove(table);
dependsOn.put(table, dependencies);
}
for (String table : tables) {
@ -388,4 +380,19 @@ public class FullBackupImporter extends FullBackupBase {
super("Tried to import a backup with version " + backupVersion + " into a database with version " + currentVersion);
}
}
public static class ForeignKeyViolationException extends IOException {
public ForeignKeyViolationException(List<SqlUtil.ForeignKeyViolation> violations) {
super(buildMessage(violations));
}
private static String buildMessage(List<SqlUtil.ForeignKeyViolation> violations) {
Set<String> unique = violations
.stream()
.map(it -> it.getTable() + " -> " + it.getColumn())
.collect(Collectors.toSet());
return Util.join(unique, ", ");
}
}
}

View file

@ -32,6 +32,7 @@ import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkViewModel
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {

View file

@ -0,0 +1,102 @@
package org.thoughtcrime.securesms.calls.links
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
@Preview
@Composable
private fun SignalCallRowPreview() {
SignalTheme(false) {
SignalCallRow(
callName = "Call Name",
callLink = "https://call.signal.org#blahblahblah",
onJoinClicked = {}
)
}
}
@Composable
fun SignalCallRow(
callName: String,
callLink: String,
onJoinClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.border(
width = 1.25.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(18.dp)
)
.padding(16.dp)
) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
modifier = Modifier
.size(64.dp)
.background(
color = Color(0xFFE5E5FE),
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.align(CenterVertically)
) {
Text(
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = callLink,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(10.dp))
Buttons.Small(
onClick = onJoinClicked,
modifier = Modifier.align(CenterVertically)
) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__join))
}
}
}

View file

@ -1,36 +1,23 @@
package org.thoughtcrime.securesms.calls.links
package org.thoughtcrime.securesms.calls.links.create
import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@ -42,6 +29,8 @@ import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
@ -68,7 +57,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
val callLink: String by viewModel.callLink
val approveAllMembers: Boolean by viewModel.approveAllMembers
Handle(modifier = Modifier.align(CenterHorizontally))
Handle(modifier = Modifier.align(Alignment.CenterHorizontally))
Spacer(modifier = Modifier.height(20.dp))
@ -125,7 +114,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
onClick = this@CreateCallLinkBottomSheetDialogFragment::onDoneClicked,
modifier = Modifier
.padding(end = dimensionResource(id = R.dimen.core_ui__gutter))
.align(End)
.align(Alignment.End)
) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__done))
}
@ -182,61 +171,3 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
}
}
}
@Composable
private fun SignalCallRow(
callName: String,
callLink: String,
onJoinClicked: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.border(
width = 1.25.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(18.dp)
)
.padding(16.dp)
) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
modifier = Modifier
.size(64.dp)
.background(
color = Color(0xFFE5E5FE),
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.align(CenterVertically)
) {
Text(
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = callLink,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(10.dp))
Buttons.Small(
onClick = onJoinClicked,
modifier = Modifier.align(CenterVertically)
) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__join))
}
}
}

View file

@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.calls.links
package org.thoughtcrime.securesms.calls.links.create
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State

View file

@ -65,7 +65,8 @@ class CallLogAdapter(
fun submitCallRows(
rows: List<CallLogRow?>,
selectionState: CallLogSelectionState,
stagedDeletion: CallLogStagedDeletion?
stagedDeletion: CallLogStagedDeletion?,
onCommit: () -> Unit
): Int {
val filteredRows = rows
.filterNotNull()
@ -78,7 +79,7 @@ class CallLogAdapter(
}
}
submitList(filteredRows)
submitList(filteredRows, onCommit)
return filteredRows.size
}

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.calls.log
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
@ -18,11 +19,15 @@ class CallLogContextMenu(
private val fragment: Fragment,
private val callbacks: Callbacks
) {
fun show(anchor: View, call: CallLogRow.Call) {
fun show(recyclerView: RecyclerView, anchor: View, call: CallLogRow.Call) {
recyclerView.suppressLayout(true)
anchor.isSelected = true
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.onDismiss { anchor.isSelected = false }
.onDismiss {
anchor.isSelected = false
recyclerView.suppressLayout(false)
}
.show(
listOfNotNull(
getVideoCallActionItem(call),

View file

@ -11,15 +11,18 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.SharedElementCallback
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
@ -27,6 +30,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
@ -47,12 +51,12 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SnapToTopDataObserver
import org.thoughtcrime.securesms.util.SnapToTopDataObserver.ScrollRequestValidator
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
import java.util.concurrent.TimeUnit
/**
* Call Log tab.
@ -60,10 +64,6 @@ import java.util.Objects
@SuppressLint("DiscouragedApi")
class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks {
companion object {
private const val LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD = 25
}
private val viewModel: CallLogViewModel by viewModels()
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
private val disposables = LifecycleDisposable()
@ -99,35 +99,27 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
initializeSharedElementTransition()
val adapter = CallLogAdapter(this)
disposables.bindTo(viewLifecycleOwner)
adapter.setPagingController(viewModel.controller)
val snapToTopDataObserver = SnapToTopDataObserver(
binding.recycler,
object : ScrollRequestValidator {
override fun isPositionStillValid(position: Int): Boolean {
return position < adapter.itemCount && position >= 0
}
override fun isItemAtPositionLoaded(position: Int): Boolean {
return adapter.getItem(position) != null
}
}
) {
val layoutManager = binding.recycler.layoutManager as? LinearLayoutManager ?: return@SnapToTopDataObserver
if (layoutManager.findFirstVisibleItemPosition() <= LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD) {
binding.recycler.smoothScrollToPosition(0)
} else {
binding.recycler.scrollToPosition(0)
}
}
val scrollToPositionDelegate = ScrollToPositionDelegate(
recyclerView = binding.recycler,
canJumpToPosition = { adapter.isAvailableAround(it) }
)
disposables += scrollToPositionDelegate
disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (data, selected) ->
val filteredCount = adapter.submitCallRows(data, selected.first, selected.second)
val filteredCount = adapter.submitCallRows(
data,
selected.first,
selected.second,
scrollToPositionDelegate::notifyListCommitted
)
binding.emptyState.visible = filteredCount == 0
}
@ -167,8 +159,8 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
)
initializePullToFilter()
initializeTapToScrollToTop(snapToTopDataObserver)
initializePullToFilter(scrollToPositionDelegate)
initializeTapToScrollToTop(scrollToPositionDelegate)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
@ -195,11 +187,31 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
viewModel.markAllCallEventsRead()
}
private fun initializeTapToScrollToTop(snapToTopDataObserver: SnapToTopDataObserver) {
private fun initializeSharedElementTransition() {
ViewCompat.setTransitionName(binding.fab, "new_convo_fab")
ViewCompat.setTransitionName(binding.fabSharedElementTarget, "camera_fab")
sharedElementEnterTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.change_transform_fabs)
setEnterSharedElementCallback(object : SharedElementCallback() {
override fun onSharedElementStart(sharedElementNames: MutableList<String>?, sharedElements: MutableList<View>?, sharedElementSnapshots: MutableList<View>?) {
if (sharedElementNames?.contains("camera_fab") == true) {
this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_edit_24)
disposables += Single.timer(200, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_phone_plus_24)
this@CallLogFragment.binding.fabSharedElementTarget.alpha = 0f
}
}
}
})
}
private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) {
disposables += tabsViewModel.tabClickEvents
.filter { it == ConversationListTab.CALLS }
.subscribeBy(onNext = {
snapToTopDataObserver.requestScrollPosition(0)
scrollToPositionDelegate.resetScrollPosition()
})
}
@ -245,13 +257,18 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
private fun initializePullToFilter() {
private fun initializePullToFilter(scrollToPositionDelegate: ScrollToPositionDelegate) {
val collapsingToolbarLayout = binding.collapsingToolbar
val openHeight = DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT).toInt()
binding.pullView.onFilterStateChanged = OnFilterStateChanged { state: FilterPullState?, source: ConversationFilterSource ->
when (state) {
FilterPullState.CLOSING -> viewModel.setFilter(CallLogFilter.ALL)
FilterPullState.CLOSING -> {
viewModel.setFilter(CallLogFilter.ALL)
binding.recycler.doAfterNextLayout {
scrollToPositionDelegate.resetScrollPosition()
}
}
FilterPullState.OPENING -> {
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
viewModel.setFilter(CallLogFilter.MISSED)
@ -277,7 +294,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
override fun canStartNestedScroll(): Boolean {
return !callLogActionMode.isInActionMode() || !isSearchOpen() || binding.pullView.isCloseable()
return !callLogActionMode.isInActionMode() && !isSearchOpen()
}
}
@ -301,7 +318,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
override fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean {
callLogContextMenu.show(itemView, callLogRow)
callLogContextMenu.show(binding.recycler, itemView, callLogRow)
return true
}
@ -376,12 +393,14 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
requireListener<Callback>().onMultiSelectStarted()
signalBottomActionBarController.setVisibility(true)
binding.fab.visible = false
return actionMode
}
override fun onActionModeWillEnd() {
requireListener<Callback>().onMultiSelectFinished()
signalBottomActionBarController.setVisibility(false)
binding.fab.visible = true
}
override fun getResources(): Resources = resources

View file

@ -51,10 +51,11 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
}
fun deleteAllCallLogsExcept(
selectedCallRowIds: Set<Long>
selectedCallRowIds: Set<Long>,
missedOnly: Boolean
): Completable {
return Completable.fromAction {
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds)
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds, missedOnly)
}.observeOn(Schedulers.io())
}
}

View file

@ -6,6 +6,7 @@ import androidx.annotation.MainThread
* Encapsulates a single deletion action
*/
class CallLogStagedDeletion(
private val filter: CallLogFilter,
private val stateSnapshot: CallLogSelectionState,
private val repository: CallLogRepository
) {
@ -35,7 +36,7 @@ class CallLogStagedDeletion(
.toSet()
if (stateSnapshot.isExclusionary()) {
repository.deleteAllCallLogsExcept(callRowIds).subscribe()
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).subscribe()
} else {
repository.deleteSelectedCallLogs(callRowIds).subscribe()
}

View file

@ -101,6 +101,7 @@ class CallLogViewModel(
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
it.filter,
CallLogSelectionState.empty().toggle(call.id),
callLogRepository
)
@ -114,6 +115,7 @@ class CallLogViewModel(
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
it.filter,
it.selectionState,
callLogRepository
)

View file

@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
public class AnimatingToggle extends FrameLayout {
private View current;
private View previous;
private final Animation inAnimation;
private final Animation outAnimation;
@ -55,9 +55,17 @@ public class AnimatingToggle extends FrameLayout {
public void display(@Nullable View view) {
if (view == current && current.getVisibility() == View.VISIBLE) return;
if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE);
if (view != null) ViewUtil.animateIn(view, inAnimation);
if (previous != null && previous.getAnimation() == outAnimation) {
previous.clearAnimation();
previous.setVisibility(View.GONE);
}
if (current != null) {
ViewUtil.animateOut(current, outAnimation, View.GONE);
}
if (view != null) {
ViewUtil.animateIn(view, inAnimation);
}
previous = current;
current = view;
}

View file

@ -28,6 +28,7 @@ import com.airbnb.lottie.model.KeyPath;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
@ -35,7 +36,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
@ -141,8 +141,8 @@ public class ConversationItemFooter extends ConstraintLayout {
timerView.stopAnimation();
}
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
presentDate(messageRecord, locale);
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull ConversationItemDisplayMode displayMode) {
presentDate(messageRecord, locale, displayMode);
presentSimInfo(messageRecord);
presentTimer(messageRecord, timerView);
presentInsecureIndicator(messageRecord);
@ -216,7 +216,7 @@ public class ConversationItemFooter extends ConstraintLayout {
}
}
public TextView getDateView() {
public View getDateView() {
return dateView;
}
@ -298,13 +298,13 @@ public class ConversationItemFooter extends ConstraintLayout {
return speedToggleHitRect;
}
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull ConversationItemDisplayMode displayMode) {
dateView.forceLayout();
if (messageRecord.isFailed()) {
int errorMsg;
if (messageRecord.hasFailedWithNetworkFailures()) {
errorMsg = R.string.ConversationItem_error_network_not_delivered;
} else if (messageRecord.getRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) {
} else if (messageRecord.getToRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) {
errorMsg = R.string.ConversationItem_error_partially_not_delivered;
} else {
errorMsg = R.string.ConversationItem_error_not_sent_tap_for_details;
@ -318,7 +318,11 @@ public class ConversationItemFooter extends ConstraintLayout {
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
} else {
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
String date = DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp());
if (displayMode != ConversationItemDisplayMode.DETAILED && messageRecord instanceof MediaMmsMessageRecord && ((MediaMmsMessageRecord) messageRecord).isEditMessage()) {
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
}
dateView.setText(date);
}
}
@ -342,13 +346,11 @@ public class ConversationItemFooter extends ConstraintLayout {
}
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
SignalExecutors.BOUNDED.execute(() -> {
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
long id = messageRecord.getId();
long now = System.currentTimeMillis();
long id = messageRecord.getId();
SignalDatabase.messages().markExpireStarted(id);
expirationManager.scheduleDeletion(id, messageRecord.getExpiresIn());
SignalDatabase.messages().markExpireStarted(id, now);
ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(id, true, now, messageRecord.getExpiresIn());
});
}
} else {
@ -364,7 +366,7 @@ public class ConversationItemFooter extends ConstraintLayout {
long newMessageId = buildMessageId(messageRecord);
if (previousMessageId == newMessageId && deliveryStatusView.isPending() && !messageRecord.isPending()) {
if (messageRecord.getRecipient().isGroup()) {
if (messageRecord.getToRecipient().isGroup()) {
SignalLocalMetrics.GroupMessageSend.onUiUpdated(messageRecord.getId());
} else {
SignalLocalMetrics.IndividualMessageSend.onUiUpdated(messageRecord.getId());
@ -407,7 +409,7 @@ public class ConversationItemFooter extends ConstraintLayout {
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
showAudioDurationViews();
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getRecipient(), Recipient.self()))) {
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getToRecipient(), Recipient.self()))) {
revealDot.setProgress(1f);
} else {
revealDot.setProgress(0f);

View file

@ -5,6 +5,7 @@ import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.text.SpannableString;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.view.KeyEvent;
@ -39,9 +40,13 @@ import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
import org.thoughtcrime.securesms.database.DraftTable;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@ -53,6 +58,7 @@ import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
@ -88,6 +94,8 @@ public class InputPanel extends LinearLayout
private View recordingContainer;
private View recordLockCancel;
private ViewGroup composeContainer;
private View editMessageLabel;
private View editMessageCancel;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@ -105,6 +113,7 @@ public class InputPanel extends LinearLayout
private boolean hideForSelection;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
private MessageRecord messageToEdit;
public InputPanel(Context context) {
super(context);
@ -144,6 +153,8 @@ public class InputPanel extends LinearLayout
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction(false));
this.editMessageLabel = findViewById(R.id.edit_message);
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
@ -167,6 +178,8 @@ public class InputPanel extends LinearLayout
stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
stickerSuggestion.setAdapter(stickerSuggestionAdapter);
editMessageCancel.setOnClickListener(v -> exitEditMessageMode());
}
public void setListener(final @NonNull Listener listener) {
@ -183,7 +196,7 @@ public class InputPanel extends LinearLayout
public void setQuote(@NonNull GlideRequests glideRequests,
long id,
@NonNull Recipient author,
@NonNull CharSequence body,
@Nullable CharSequence body,
@NonNull SlideDeck attachments,
@NonNull QuoteModel.Type quoteType)
{
@ -372,6 +385,52 @@ public class InputPanel extends LinearLayout
quoteView.setWallpaperEnabled(enabled);
}
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage messageToEdit, boolean fromDraft) {
SpannableString textToEdit = messageToEdit.getDisplayBody(getContext());
if (!fromDraft) {
composeText.setText(textToEdit);
composeText.setSelection(textToEdit.length());
}
Quote quote = MessageRecordUtil.getQuote(messageToEdit.getMessageRecord());
if (quote == null) {
clearQuote();
} else {
setQuote(glideRequests, quote.getId(), Recipient.resolved(quote.getAuthor()), quote.getDisplayText(), quote.getAttachment(), quote.getQuoteType());
}
this.messageToEdit = messageToEdit.getMessageRecord();
updateEditModeUi();
}
public void exitEditMessageMode() {
if (messageToEdit != null) {
composeText.setText("");
messageToEdit = null;
quoteView.setMessageType(QuoteView.MessageType.PREVIEW);
}
updateEditModeUi();
}
private void updateEditModeUi() {
if (inEditMessageMode()) {
ViewUtil.focusAndShowKeyboard(composeText);
editMessageLabel.setVisibility(View.VISIBLE);
editMessageCancel.setVisibility(View.VISIBLE);
if (listener != null) {
listener.onEnterEditMode();
}
} else {
editMessageLabel.setVisibility(View.GONE);
editMessageCancel.setVisibility(View.GONE);
if (listener != null) {
listener.onExitEditMode();
}
}
}
public boolean inEditMessageMode() {
return messageToEdit != null;
}
public void setHideForMessageRequestState(boolean hideForMessageRequestState) {
this.hideForMessageRequestState = hideForMessageRequestState;
updateVisibility();
@ -617,6 +676,16 @@ public class InputPanel extends LinearLayout
}
}
public @Nullable MessageRecord getEditMessage() {
return messageToEdit;
}
public @Nullable MessageId getEditMessageId() {
if (messageToEdit == null) {
return null;
}
return new MessageId(messageToEdit.getId());
}
public interface Listener extends VoiceNoteDraftView.Listener {
void onRecorderStarted();
void onRecorderLocked();
@ -628,6 +697,8 @@ public class InputPanel extends LinearLayout
void onStickerSuggestionSelected(@NonNull StickerRecord sticker);
void onQuoteChanged(long id, @NonNull RecipientId author);
void onQuoteCleared();
void onEnterEditMode();
void onExitEditMode();
}
private static class SlideToCancel {

View file

@ -1,10 +1,6 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Insets;
import android.graphics.Rect;
import android.os.Build;
import android.util.AttributeSet;
import android.view.WindowInsets;
@ -12,12 +8,17 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.Guideline;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
public class InsetAwareConstraintLayout extends ConstraintLayout {
private WindowInsetsTypeProvider windowInsetsTypeProvider = WindowInsetsTypeProvider.ALL;
private Insets insets;
public InsetAwareConstraintLayout(@NonNull Context context) {
super(context);
}
@ -30,30 +31,21 @@ public class InsetAwareConstraintLayout extends ConstraintLayout {
super(context, attrs, defStyleAttr);
}
public void setWindowInsetsTypeProvider(@NonNull WindowInsetsTypeProvider windowInsetsTypeProvider) {
this.windowInsetsTypeProvider = windowInsetsTypeProvider;
requestLayout();
}
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
if (Build.VERSION.SDK_INT < 30) {
return super.onApplyWindowInsets(insets);
}
Insets windowInsets = insets.getInsets(WindowInsets.Type.systemBars() | WindowInsets.Type.ime() | WindowInsets.Type.displayCutout());
applyInsets(new Rect(windowInsets.left, windowInsets.top, windowInsets.right, windowInsets.bottom));
WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets);
Insets newInsets = windowInsetsCompat.getInsets(windowInsetsTypeProvider.getInsetsType());
applyInsets(newInsets);
return super.onApplyWindowInsets(insets);
}
@Override
protected boolean fitSystemWindows(Rect insets) {
if (Build.VERSION.SDK_INT >= 30) {
return true;
}
applyInsets(insets);
return true;
}
private void applyInsets(@NonNull Rect insets) {
public void applyInsets(@NonNull Insets insets) {
Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline);
Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline);
Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline);
@ -83,4 +75,15 @@ public class InsetAwareConstraintLayout extends ConstraintLayout {
}
}
}
public interface WindowInsetsTypeProvider {
WindowInsetsTypeProvider ALL = () ->
WindowInsetsCompat.Type.ime() |
WindowInsetsCompat.Type.systemBars() |
WindowInsetsCompat.Type.displayCutout();
@WindowInsetsCompat.Type.InsetsType
int getInsetsType();
}
}

View file

@ -221,6 +221,10 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
}
private int getDeviceRotation() {
if (isInEditMode()) {
return Surface.ROTATION_0;
}
if (Build.VERSION.SDK_INT >= 30) {
getContext().getDisplay().getRealMetrics(displayMetrics);
} else {

View file

@ -202,6 +202,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
params.width = thumbWidth;
thumbnailView.setLayoutParams(params);
dismissView.setVisibility(messageType == MessageType.PREVIEW ? View.VISIBLE : View.GONE);
}
public void setQuote(GlideRequests glideRequests,

View file

@ -0,0 +1,201 @@
package org.thoughtcrime.securesms.components
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.doAfterNextLayout
import kotlin.math.abs
import kotlin.math.max
/**
* Delegate object to help manage scroll position requests.
*
* @param recyclerView The recycler view that will be scrolled
* @param canJumpToPosition Allows additional checks to see if we can scroll. For example, PagingMappingAdapter#isAvailableAround
* @param mapToTruePosition Allows additional offsets to be applied to the position.
*/
class ScrollToPositionDelegate private constructor(
private val recyclerView: RecyclerView,
canJumpToPosition: (Int) -> Boolean,
mapToTruePosition: (Int) -> Int,
disposables: CompositeDisposable
) : Disposable by disposables {
companion object {
private val TAG = Log.tag(ScrollToPositionDelegate::class.java)
const val NO_POSITION = -1
private const val SMOOTH_SCROLL_THRESHOLD = 25
private const val SCROLL_ANIMATION_THRESHOLD = 50
private val EMPTY = ScrollToPositionRequest(
position = NO_POSITION,
smooth = true,
awaitLayout = true,
scrollStrategy = DefaultScrollStrategy
)
}
private val listCommitted = BehaviorSubject.create<Unit>()
private val scrollPositionRequested = BehaviorSubject.createDefault(EMPTY)
private val scrollPositionRequests: Observable<ScrollToPositionRequest> = Observable.combineLatest(listCommitted, scrollPositionRequested) { _, b -> b }
constructor(
recyclerView: RecyclerView,
canJumpToPosition: (Int) -> Boolean = { true },
mapToTruePosition: (Int) -> Int = { it }
) : this(recyclerView, canJumpToPosition, mapToTruePosition, CompositeDisposable())
init {
disposables += scrollPositionRequests
.observeOn(AndroidSchedulers.mainThread())
.filter { it.position >= 0 && canJumpToPosition(it.position) }
.map { it.copy(position = mapToTruePosition(it.position)) }
.subscribeBy(onNext = { position ->
if (position.awaitLayout) {
recyclerView.doAfterNextLayout {
handleScrollPositionRequest(position, recyclerView)
}
} else {
recyclerView.post {
handleScrollPositionRequest(position, recyclerView)
}
}
if (!(recyclerView.isLayoutRequested || recyclerView.isInLayout)) {
recyclerView.requestLayout()
}
})
}
/**
* Entry point for requesting a specific scroll position.
*
* @param position The desired position to jump to. -1 to clear the current request.
* @param smooth Whether a smooth scroll will be attempted. Only done if we are within a certain distance.
* @param awaitLayout Whether this scroll should await for the next layout to complete before being attempted.
* @param scrollStrategy See [ScrollStrategy]
*/
fun requestScrollPosition(
position: Int,
smooth: Boolean = true,
awaitLayout: Boolean = true,
scrollStrategy: ScrollStrategy = DefaultScrollStrategy
) {
scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, awaitLayout, scrollStrategy))
}
/**
* Reset the scroll position to 0
*/
fun resetScrollPosition() {
requestScrollPosition(0, true)
}
/**
* This should be called every time a list is submitted to the RecyclerView's adapter.
*/
fun notifyListCommitted() {
listCommitted.onNext(Unit)
}
fun isListCommitted(): Boolean = listCommitted.value != null
private fun handleScrollPositionRequest(
request: ScrollToPositionRequest,
recyclerView: RecyclerView
) {
requestScrollPosition(NO_POSITION, false)
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager
if (layoutManager == null) {
Log.w(TAG, "Layout manager is not set or of an invalid type.")
return
}
if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_DRAGGING) {
return
}
val position = max(0, request.position)
request.scrollStrategy.performScroll(
recyclerView,
layoutManager,
position,
request.smooth
)
}
private data class ScrollToPositionRequest(
val position: Int,
val smooth: Boolean,
val awaitLayout: Boolean,
val scrollStrategy: ScrollStrategy
)
/**
* Jumps to the desired position, pinning it to the "top" of the recycler.
*/
object DefaultScrollStrategy : ScrollStrategy {
override fun performScroll(
recyclerView: RecyclerView,
layoutManager: LinearLayoutManager,
position: Int,
smooth: Boolean
) {
val offset = when {
position == 0 -> 0
layoutManager.reverseLayout -> recyclerView.height
else -> 0
}
Log.d(TAG, "Scrolling to $position")
if (smooth && position == 0 && layoutManager.findFirstVisibleItemPosition() < SMOOTH_SCROLL_THRESHOLD) {
recyclerView.smoothScrollToPosition(position)
} else {
layoutManager.scrollToPositionWithOffset(position, offset)
}
}
}
/**
* Jumps to the given position but tries to ensure that the contents are completely visible on screen.
*/
object JumpToPositionStrategy : ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
if (abs(layoutManager.findFirstVisibleItemPosition() - position) < SCROLL_ANIMATION_THRESHOLD) {
val child: View? = layoutManager.findViewByPosition(position)
if (child == null || !layoutManager.isViewPartiallyVisible(child, true, false)) {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 4)
}
} else {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 4)
}
}
}
/**
* Performs the actual scrolling for a given request.
*/
interface ScrollStrategy {
/**
* @param recyclerView The recycler view which is to be scrolled
* @param layoutManager The typed layout manager attached to the recycler view
* @param position The position we should scroll to.
* @param smooth Whether or not a smooth scroll should be attempted
*/
fun performScroll(
recyclerView: RecyclerView,
layoutManager: LinearLayoutManager,
position: Int,
smooth: Boolean
)
}
}

View file

@ -1,74 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
/**
* Displays the data in a given UnreadPayments object in a banner.
*/
public class UnreadPaymentsView extends ConstraintLayout {
private TextView title;
private AvatarImageView avatar;
private Listener listener;
public UnreadPaymentsView(@NonNull Context context) {
super(context);
}
public UnreadPaymentsView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public UnreadPaymentsView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public UnreadPaymentsView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
title = findViewById(R.id.payment_notification_title);
avatar = findViewById(R.id.payment_notification_avatar);
View open = findViewById(R.id.payment_notification_touch_target);
View close = findViewById(R.id.payment_notification_close_touch_target);
open.setOnClickListener(v -> {
if (listener != null) listener.onOpenPaymentsNotificationClicked();
});
close.setOnClickListener(v -> {
if (listener != null) listener.onClosePaymentsNotificationClicked();
});
}
public void setListener(@NonNull Listener listener) {
this.listener = listener;
}
public void setUnreadPayments(@NonNull UnreadPayments unreadPayments) {
title.setText(unreadPayments.getDescription(getContext()));
avatar.setAvatar(unreadPayments.getRecipient());
avatar.setVisibility(unreadPayments.getRecipient() == null ? GONE : VISIBLE);
}
public interface Listener {
void onOpenPaymentsNotificationClicked();
void onClosePaymentsNotificationClicked();
}
}

View file

@ -36,7 +36,6 @@ import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable;
import org.thoughtcrime.securesms.util.Util;
import java.util.Arrays;
@ -109,8 +108,13 @@ public class EmojiTextView extends AppCompatTextView {
setEmojiCompatEnabled(useSystemEmoji());
}
public void setMaxLength(int maxLength) {
this.maxLength = maxLength;
setText(getText());
}
public void enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory();
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw);
setSpannableFactory(spoilerFilteringSpannableFactory);
}
@ -316,23 +320,7 @@ public class EmojiTextView extends AppCompatTextView {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();
SpannableString shortenedText = new SpannableString(getText().subSequence(0, maxLength));
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(shortenedText, maxLength - 1, maxLength);
if (!mentionAnnotations.isEmpty()) {
shortenedText = new SpannableString(shortenedText.subSequence(0, shortenedText.getSpanStart(mentionAnnotations.get(0))));
}
Object[] endSpans = shortenedText.getSpans(shortenedText.length() - 1, shortenedText.length(), Object.class);
for (Object span : endSpans) {
if (shortenedText.getSpanFlags(span) == Spanned.SPAN_EXCLUSIVE_INCLUSIVE) {
int start = shortenedText.getSpanStart(span);
int end = shortenedText.getSpanEnd(span);
shortenedText.removeSpan(span);
shortenedText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
newContent.append(shortenedText)
newContent.append(getText(maxLength))
.append(ELLIPSIS)
.append(Util.emptyIfNull(overflowText));
@ -375,9 +363,12 @@ public class EmojiTextView extends AppCompatTextView {
CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END));
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))
.append(ellipsized.subSequence(0, ellipsized.length()))
.append(Optional.ofNullable(overflowText).orElse(""));
newContent.append(getText().subSequence(0, overflowStart).toString())
.append(ellipsized.subSequence(0, ellipsized.length()).toString());
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
newContent.append(Optional.ofNullable(overflowText).orElse(""));
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
@ -402,6 +393,27 @@ public class EmojiTextView extends AppCompatTextView {
}
}
/** Get text but truncated to maxLength, adjusts for end mentions and converts style spans to be exclusive on start and end. */
private SpannableString getText(int maxLength) {
SpannableString shortenedText = new SpannableString(getText().subSequence(0, maxLength));
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(shortenedText, maxLength - 1, maxLength);
if (!mentionAnnotations.isEmpty()) {
shortenedText = new SpannableString(shortenedText.subSequence(0, shortenedText.getSpanStart(mentionAnnotations.get(0))));
}
Object[] endSpans = shortenedText.getSpans(shortenedText.length() - 1, shortenedText.length(), Object.class);
for (Object span : endSpans) {
if (shortenedText.getSpanFlags(span) == Spanned.SPAN_EXCLUSIVE_INCLUSIVE) {
int start = shortenedText.getSpanStart(span);
int end = shortenedText.getSpanEnd(span);
shortenedText.removeSpan(span);
shortenedText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
return shortenedText;
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
return Util.equals(previousText, text) &&
Util.equals(previousOverflowText, overflowText) &&
@ -454,15 +466,4 @@ public class EmojiTextView extends AppCompatTextView {
mentionRendererDelegate.setTint(mentionBackgroundTint);
}
}
private class SpoilerFilteringSpannableFactory extends Spannable.Factory {
@Override
public @NonNull Spannable newSpannable(CharSequence source) {
return wrap(super.newSpannable(source));
}
@NonNull SpoilerFilteringSpannable wrap(Spannable source) {
return new SpoilerFilteringSpannable(source, () -> isInOnDraw);
}
}
}

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.graphics.Canvas
import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.util.AttributeSet
@ -20,6 +21,8 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
private var bufferType: BufferType? = null
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
private val spoilerRendererDelegate: SpoilerRendererDelegate
private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null
private var isInOnDraw: Boolean = false
init {
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
@ -27,6 +30,8 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
override fun onDraw(canvas: Canvas) {
isInOnDraw = true
if (text is Spanned && layout != null) {
val checkpoint = canvas.save()
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
@ -37,6 +42,8 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
}
super.onDraw(canvas)
isInOnDraw = false
}
override fun setText(text: CharSequence?, type: BufferType?) {
@ -56,11 +63,16 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
EmojiProvider.emojify(newCandidates, text, this, false)
}
val newContent = if (width == 0 || maxLines == -1) {
var newContent: CharSequence? = if (width == 0 || maxLines == -1) {
newText
} else {
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
}
if (newContent is Spannable && spoilerFilteringSpannableFactory != null) {
newContent = spoilerFilteringSpannableFactory!!.wrap(newContent)
}
bufferType = BufferType.SPANNABLE
super.setText(newContent, type)
}
@ -74,4 +86,9 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
}
}
fun enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw }
setSpannableFactory(spoilerFilteringSpannableFactory!!)
}
}

View file

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.components.emoji
import android.text.Spannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider
/**
* Spannable factory used to help ensure spans are copied/maintained properly through the
* Android text handling system.
*
* @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw
*/
class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() {
override fun newSpannable(source: CharSequence): Spannable {
return wrap(super.newSpannable(source))
}
fun wrap(source: Spannable): SpoilerFilteringSpannable {
return SpoilerFilteringSpannable(source, inOnDraw)
}
}

View file

@ -208,16 +208,22 @@ class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPr
private val switchWidget: MaterialSwitch = itemView.findViewById(R.id.switch_widget)
override fun bind(model: SwitchPreference) {
super.bind(model)
switchWidget.setOnCheckedChangeListener(null)
switchWidget.isEnabled = model.isEnabled
switchWidget.isChecked = model.isChecked
switchWidget.setOnCheckedChangeListener { _, _ ->
model.onClick()
}
if (payload.contains(SwitchPreference.PAYLOAD_CHECKED)) {
return
}
super.bind(model)
switchWidget.isEnabled = model.isEnabled
itemView.setOnClickListener {
model.onClick()
}

View file

@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EdgeEffect
import androidx.annotation.Discouraged
import androidx.annotation.LayoutRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
@ -15,7 +14,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.WindowUtil
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
abstract class DSLSettingsBottomSheetFragment(
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },

View file

@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.components.settings.app.appearance
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import org.signal.core.util.concurrent.observe
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.appearance.navbar.ChooseNavigationBarStyleFragment
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -30,10 +32,25 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
childFragmentManager.setFragmentResultListener(ChooseNavigationBarStyleFragment.REQUEST_KEY, viewLifecycleOwner) { key, bundle ->
if (bundle.getBoolean(key, false)) {
viewModel.refreshState()
}
}
}
private fun getConfiguration(state: AppearanceSettingsState): DSLConfiguration {
return configure {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__language),
listItems = languageLabels,
selected = languageValues.indexOf(state.language),
onSelected = {
viewModel.setLanguage(languageValues[it])
}
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__theme),
listItems = themeLabels,
@ -59,12 +76,17 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
}
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__language),
listItems = languageLabels,
selected = languageValues.indexOf(state.language),
onSelected = {
viewModel.setLanguage(languageValues[it])
clickPref(
title = DSLSettingsText.from(R.string.preferences_navigation_bar_size),
summary = DSLSettingsText.from(
if (state.isCompactNavigationBar) {
R.string.preferences_compact
} else {
R.string.preferences_normal
}
),
onClick = {
ChooseNavigationBarStyleFragment().show(childFragmentManager, null)
}
)
}

View file

@ -5,5 +5,6 @@ import org.thoughtcrime.securesms.keyvalue.SettingsValues
data class AppearanceSettingsState(
val theme: SettingsValues.Theme,
val messageFontSize: Int,
val language: String
val language: String,
val isCompactNavigationBar: Boolean
)

View file

@ -1,28 +1,26 @@
package org.thoughtcrime.securesms.components.settings.app.appearance
import android.app.Activity
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Flowable
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.keyvalue.SettingsValues.Theme
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.SplashScreenUtil
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.util.rx.RxStore
class AppearanceSettingsViewModel : ViewModel() {
private val store: Store<AppearanceSettingsState>
private val store = RxStore(getState())
val state: Flowable<AppearanceSettingsState> = store.stateFlowable
init {
val initialState = AppearanceSettingsState(
SignalStore.settings().theme,
SignalStore.settings().messageFontSize,
SignalStore.settings().language
)
store = Store(initialState)
override fun onCleared() {
super.onCleared()
store.dispose()
}
val state: LiveData<AppearanceSettingsState> = store.stateLiveData
fun refreshState() {
store.update { getState() }
}
fun setTheme(activity: Activity?, theme: Theme) {
store.update { it.copy(theme = theme) }
@ -40,4 +38,13 @@ class AppearanceSettingsViewModel : ViewModel() {
store.update { it.copy(messageFontSize = size) }
SignalStore.settings().messageFontSize = size
}
private fun getState(): AppearanceSettingsState {
return AppearanceSettingsState(
SignalStore.settings().theme,
SignalStore.settings().messageFontSize,
SignalStore.settings().language,
SignalStore.settings().useCompactNavigationBar
)
}
}

View file

@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.components.settings.app.appearance.navbar
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.View
import androidx.annotation.DrawableRes
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ChooseNavigationBarStyleFragmentBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
/**
* Allows the user to choose between a compact and full-sized navigation bar.
*/
class ChooseNavigationBarStyleFragment : DialogFragment(R.layout.choose_navigation_bar_style_fragment) {
private val binding by ViewBinderDelegate(ChooseNavigationBarStyleFragmentBinding::bind)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return dialog
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
presentToggleState(SignalStore.settings().useCompactNavigationBar)
binding.toggle.addOnButtonCheckedListener { group, checkedId, isChecked ->
if (isChecked) {
presentToggleState(checkedId == R.id.compact)
}
}
binding.ok.setOnClickListener {
val isCompact = binding.toggle.checkedButtonId == R.id.compact
SignalStore.settings().useCompactNavigationBar = isCompact
dismissAllowingStateLoss()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true))
}
}
private fun presentToggleState(isCompact: Boolean) {
binding.toggle.check(if (isCompact) R.id.compact else R.id.normal)
binding.image.setImageResource(PreviewImages.getImageResourceId(isCompact))
binding.normal.setIconResource(if (isCompact) 0 else R.drawable.ic_check_20)
binding.compact.setIconResource(if (isCompact) R.drawable.ic_check_20 else 0)
}
private sealed class PreviewImages(
@DrawableRes private val compact: Int,
@DrawableRes private val normal: Int
) {
@DrawableRes
fun getImageResource(isCompact: Boolean): Int {
return if (isCompact) compact else normal
}
private object ThreeButtons : PreviewImages(
compact = R.drawable.navbar_compact,
normal = R.drawable.navbar_normal
)
private object TwoButtons : PreviewImages(
compact = R.drawable.navbar_compact_2,
normal = R.drawable.navbar_normal_2
)
companion object {
@DrawableRes
fun getImageResourceId(isCompact: Boolean): Int {
return if (FeatureFlags.callsTab()) {
ThreeButtons.getImageResource(isCompact)
} else {
TwoButtons.getImageResource(isCompact)
}
}
}
}
companion object {
const val REQUEST_KEY = "ChooseNavigationBarStyle"
}
}

View file

@ -547,6 +547,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
}
dividerPref()
switchPref(
title = DSLSettingsText.from("Use V2 ConversationFragment"),
isChecked = state.useConversationFragmentV2,
onClick = {
viewModel.setUseConversationFragmentV2(!state.useConversationFragmentV2)
}
)
}
}

View file

@ -20,5 +20,6 @@ data class InternalSettingsState(
val delayResends: Boolean,
val disableStorageService: Boolean,
val canClearOnboardingState: Boolean,
val pnpInitialized: Boolean
val pnpInitialized: Boolean,
val useConversationFragmentV2: Boolean
)

View file

@ -99,6 +99,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setUseConversationFragmentV2(enabled: Boolean) {
SignalStore.internalValues().setUseConversationFragmentV2(enabled)
refresh()
}
fun addSampleReleaseNote() {
repository.addSampleReleaseNote()
}
@ -124,7 +129,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
delayResends = SignalStore.internalValues().delayResends(),
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
pnpInitialized = SignalStore.misc().hasPniInitializedDevices()
pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
useConversationFragmentV2 = SignalStore.internalValues().useConversationFragmentV2()
)
fun onClearOnboardingState() {

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.ObservableEmitter
import io.reactivex.rxjava3.core.Single
@ -8,6 +9,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
import org.thoughtcrime.securesms.database.RxDatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -24,16 +26,11 @@ import org.thoughtcrime.securesms.util.toMillis
class NotificationProfilesRepository {
private val database: NotificationProfileDatabase = SignalDatabase.notificationProfiles
fun getProfiles(): Observable<List<NotificationProfile>> {
return Observable.create { emitter: ObservableEmitter<List<NotificationProfile>> ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val profileObserver = DatabaseObserver.Observer { emitter.onNext(database.getProfiles()) }
databaseObserver.registerNotificationProfileObserver(profileObserver)
emitter.setCancellable { databaseObserver.unregisterObserver(profileObserver) }
emitter.onNext(database.getProfiles())
}.subscribeOn(Schedulers.io())
fun getProfiles(): Flowable<List<NotificationProfile>> {
return RxDatabaseObserver
.notificationProfiles
.map { database.getProfiles() }
.subscribeOn(Schedulers.io())
}
fun getProfile(profileId: Long): Observable<NotificationProfile> {

View file

@ -3,12 +3,12 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Flowable
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
class NotificationProfilesViewModel(private val repository: NotificationProfilesRepository) : ViewModel() {
fun getProfiles(): Observable<List<NotificationProfile>> {
fun getProfiles(): Flowable<List<NotificationProfile>> {
return repository.getProfiles()
.observeOn(AndroidSchedulers.mainThread())
}

View file

@ -2,7 +2,6 @@
package org.thoughtcrime.securesms.components.settings
import androidx.annotation.CallSuper
import androidx.annotation.Discouraged
import androidx.annotation.Px
import androidx.annotation.StringRes
@ -230,7 +229,6 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
}
}
@CallSuper
override fun areContentsTheSame(newItem: T): Boolean {
return areItemsTheSame(newItem) &&
newItem.summary == summary &&
@ -293,8 +291,21 @@ class SwitchPreference(
val isChecked: Boolean,
val onClick: () -> Unit
) : PreferenceModel<SwitchPreference>() {
companion object {
const val PAYLOAD_CHECKED = "payload_checked"
}
override fun areContentsTheSame(newItem: SwitchPreference): Boolean {
return super.areContentsTheSame(newItem) && isChecked == newItem.isChecked
return false
}
override fun getChangePayload(newItem: SwitchPreference): Any? {
return if (super.areContentsTheSame(newItem)) {
PAYLOAD_CHECKED
} else {
null
}
}
}

View file

@ -2,10 +2,14 @@ package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Color
import android.text.Annotation
import android.text.Selection
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
/**
* Helper for applying spans to text that should be rendered as a spoiler. Also
@ -59,6 +63,15 @@ object SpoilerAnnotation {
override fun onClick(widget: View) {
revealedSpoilers.add(spoiler.value)
if (widget is TextView && Selection.getSelectionStart(widget.text) != -1) {
val text: Spannable = if (widget.text is Spannable) {
widget.text as Spannable
} else {
SpannableString(widget.text)
}
Selection.removeSelection(text)
widget.text = text
}
}
override fun updateDrawState(ds: TextPaint) {

View file

@ -86,7 +86,7 @@ class VoiceNoteMediaItemFactory {
Recipient threadRecipient = Objects.requireNonNull(SignalDatabase.threads()
.getRecipientForThreadId(messageRecord.getThreadId()));
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
Recipient sender = messageRecord.getFromRecipient();
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
AudioSlide audioSlide = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide();

View file

@ -9,8 +9,11 @@ import android.widget.PopupWindow
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.view.ViewCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.visible
import java.util.concurrent.TimeUnit
/**
@ -70,6 +73,7 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
iconView.setImageResource(callStateUpdate.iconRes)
}
iconView.visible = callStateUpdate.iconRes != null
descriptionView.setText(callStateUpdate.stringRes)
}
@ -78,8 +82,19 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
return
}
showAtLocation(parent, Gravity.TOP or Gravity.START, 0, 0)
measureChild()
val anchor: View = ViewCompat.requireViewById(parent, R.id.call_screen_footer_gradient_barrier)
val pill: View = ViewCompat.requireViewById(contentView, R.id.call_state_pill)
// 54 is the top margin of the contentView (30) plus the desired padding (24)
showAtLocation(
parent,
Gravity.TOP or Gravity.START,
0,
anchor.top - 54.dp - pill.measuredHeight
)
update()
dismissDebouncer.publish { dismiss() }
}

View file

@ -110,19 +110,19 @@ public class ConversationAdapter
private final Set<MultiselectPart> selected;
private final Calendar calendar;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private boolean condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private ConversationItemDisplayMode condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
public ConversationAdapter(@NonNull Context context,
@NonNull LifecycleOwner lifecycleOwner,
@ -258,7 +258,7 @@ public class ConversationAdapter
}
}
public void setCondensedMode(boolean condensedMode) {
public void setCondensedMode(ConversationItemDisplayMode condensedMode) {
this.condensedMode = condensedMode;
notifyDataSetChanged();
}
@ -283,7 +283,7 @@ public class ConversationAdapter
ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
ConversationItemDisplayMode displayMode = condensedMode ? ConversationItemDisplayMode.CONDENSED : ConversationItemDisplayMode.STANDARD;
ConversationItemDisplayMode displayMode = condensedMode != null ? condensedMode : ConversationItemDisplayMode.STANDARD;
conversationViewHolder.getBindable().bind(lifecycleOwner,
conversationMessage,
@ -295,7 +295,7 @@ public class ConversationAdapter
recipient,
searchQuery,
conversationMessage == recordToPulse,
hasWallpaper && !condensedMode,
hasWallpaper && displayMode.displayWallpaper(),
isMessageRequestAccepted,
conversationMessage == inlineContent,
colorizer,
@ -402,6 +402,39 @@ public class ConversationAdapter
}
}
/**
* Checks a range around the given position for nulls.
*
* @param position The position we wish to jump to.
* @return true if we seem like we've paged in the right data, false if not so.
*/
public boolean canJumpToPosition(int position) {
position = isTypingViewEnabled() ? position - 1 : position;
if (position < 0) {
return false;
}
if (position > super.getItemCount()) {
Log.d(TAG, "Could not access corrected position " + position + " as it is out of bounds.");
return false;
}
int start = Math.max(position - 10, 0);
int end = Math.min(position + 5, super.getItemCount());
for (int i = start; i < end; i++) {
if (super.getItem(i) == null) {
if (pagingController != null) {
pagingController.onDataNeededAroundIndex(position);
}
return false;
}
}
return true;
}
public void setPagingController(@Nullable PagingController pagingController) {
this.pagingController = pagingController;
}
@ -431,7 +464,7 @@ public class ConversationAdapter
* an adjusted message position based on adapter state.
*/
@MainThread
int getAdapterPositionForMessagePosition(int messagePosition) {
public int getAdapterPositionForMessagePosition(int messagePosition) {
return isTypingViewEnabled() ? messagePosition + 1 : messagePosition;
}
@ -580,7 +613,7 @@ public class ConversationAdapter
* Provided a pool, this will initialize it with view counts that make sense.
*/
@MainThread
static void initializePool(@NonNull RecyclerView.RecycledViewPool pool) {
public static void initializePool(@NonNull RecyclerView.RecycledViewPool pool) {
pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_TEXT, 25);
pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_MULTIMEDIA, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_TEXT, 25);

View file

@ -22,6 +22,15 @@ data class ConversationData(
return lastSeenPosition > 0
}
fun getStartPosition(): Int {
return when {
shouldJumpToMessage() -> jumpToPosition
messageRequestData.isMessageRequestAccepted && shouldScrollToLastSeen() -> lastSeenPosition
messageRequestData.isMessageRequestAccepted -> lastScrolledPosition
else -> threadSize
}
}
data class MessageRequestData @JvmOverloads constructor(
val isMessageRequestAccepted: Boolean,
val isHidden: Boolean,

View file

@ -58,7 +58,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
/** Used once for the initial fetch, then cleared. */
private int baseSize;
ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData, boolean showUniversalExpireTimerUpdate, int baseSize) {
public ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData, boolean showUniversalExpireTimerUpdate, int baseSize) {
this.context = context;
this.threadId = threadId;
this.messageRequestData = messageRequestData;

View file

@ -46,6 +46,8 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ViewSwitcher;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
@ -103,7 +105,10 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
import org.thoughtcrime.securesms.conversation.quotes.MessageQuotesBottomSheet;
import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.conversation.v2.AddToContactsContract;
import org.thoughtcrime.securesms.conversation.v2.BubbleLayoutTransitionListener;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
@ -167,11 +172,12 @@ import org.thoughtcrime.securesms.stories.StoryViewerArgs;
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.HtmlUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalTrace;
@ -208,7 +214,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private static final String TAG = Log.tag(ConversationFragment.class);
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
private static final int CODE_ADD_EDIT_CONTACT = 77;
private static final int MAX_SCROLL_DELAY_COUNT = 5;
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
@ -247,8 +252,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private OnScrollListener conversationScrollListener;
private int lastSeenScrollOffset;
private Stopwatch startupStopwatch;
private LayoutTransition layoutTransition;
private TransitionListener transitionListener;
private View reactionsShade;
private SignalBottomActionBar bottomActionBar;
@ -262,6 +265,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private final DatabaseObserver.Observer threadDeletedObserver = this::onThreadDelete;
private ActivityResultLauncher<Intent> addToContactsLauncher;
public static void prepare(@NonNull Context context) {
FrameLayout parent = new FrameLayout(context);
parent.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT));
@ -285,6 +290,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
addToContactsLauncher = registerForActivityResult(new AddToContactsContract(), result -> {});
disposables.bindTo(getViewLifecycleOwner());
lastSeenDisposable.bindTo(getViewLifecycleOwner());
@ -293,8 +300,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
list = view.findViewById(android.R.id.list);
composeDivider = view.findViewById(R.id.compose_divider);
layoutTransition = new LayoutTransition();
transitionListener = new TransitionListener(list);
BubbleLayoutTransitionListener bubbleLayoutTransitionListener = new BubbleLayoutTransitionListener(list);
getViewLifecycleOwner().getLifecycle().addObserver(bubbleLayoutTransitionListener);
scrollToBottomButton = view.findViewById(R.id.scroll_to_bottom);
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
@ -487,7 +494,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
public void onStart() {
super.onStart();
initializeTypingObserver();
layoutTransition.getAnimator(LayoutTransition.CHANGE_DISAPPEARING).addListener(transitionListener);
}
@Override
@ -511,7 +517,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
public void onStop() {
super.onStop();
ApplicationDependencies.getTypingStatusRepository().getTypists(threadId).removeObservers(getViewLifecycleOwner());
layoutTransition.getAnimator(LayoutTransition.CHANGE_DISAPPEARING).removeListener(transitionListener);
}
@Override
@ -810,6 +815,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}));
}
if (menuState.shouldShowEditAction() && FeatureFlags.editMessageSending()) {
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> {
handleEditMessage(getSelectedConversationMessage());
if (actionMode != null) {
actionMode.finish();
}
}));
}
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleForwardMessageParts(selectedParts)));
}
@ -1052,7 +1066,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
int deleteForEveryoneResId = isNoteToSelfDelete ? R.string.ConversationFragment_delete_everywhere : R.string.ConversationFragment_delete_for_everyone;
if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis()) && (!isNoteToSelfDelete || TextSecurePreferences.isMultiDevice(requireContext()))) {
if (MessageConstraintsUtil.isValidRemoteDeleteSend(messageRecords, System.currentTimeMillis()) && (!isNoteToSelfDelete || TextSecurePreferences.isMultiDevice(requireContext()))) {
builder.setNeutralButton(deleteForEveryoneResId, (dialog, which) -> handleDeleteForEveryone(messageRecords));
}
@ -1068,7 +1082,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
private static boolean isNoteToSelfDelete(Set<MessageRecord> messageRecords) {
return messageRecords.stream().allMatch(messageRecord -> messageRecord.isOutgoing() && messageRecord.getRecipient().isSelf());
return messageRecords.stream().allMatch(messageRecord -> messageRecord.isOutgoing() && messageRecord.getToRecipient().isSelf());
}
private void handleDeleteForEveryone(Set<MessageRecord> messageRecords) {
@ -1122,6 +1136,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
listener.handleReplyMessage(message);
}
private void handleEditMessage(@NonNull ConversationMessage selectedConversationMessage) {
listener.handleEditMessage(selectedConversationMessage);
}
private void handleSaveAttachment(final MediaMmsMessageRecord message) {
if (message.isViewOnce()) {
throw new AssertionError("Cannot save a view-once message.");
@ -1416,7 +1434,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
SimpleTask.run(getLifecycle(), () -> {
return SignalDatabase.messages().getMessagePositionInConversation(threadId,
messageRecord.getDateReceived(),
messageRecord.isOutgoing() ? Recipient.self().getId() : messageRecord.getRecipient().getId());
messageRecord.getFromRecipient().getId());
}, p -> moveToPosition(p + (isTypingIndicatorShowing() ? 1 : 0), () -> {
Toast.makeText(getContext(), R.string.ConversationFragment_failed_to_open_message, Toast.LENGTH_SHORT).show();
}));
@ -1429,6 +1447,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
void openAttachmentKeyboard();
void setThreadId(long threadId);
void handleReplyMessage(ConversationMessage conversationMessage);
void handleEditMessage(@NonNull ConversationMessage conversationMessage);
void onMessageActionToolbarOpened();
void onMessageActionToolbarClosed();
void onBottomActionBarVisibilityChanged(int visibility);
@ -1767,7 +1786,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
ApplicationDependencies.getViewOnceMessageManager().scheduleIfNecessary();
ApplicationDependencies.getJobManager().add(new MultiDeviceViewOnceOpenJob(new MessageTable.SyncMessageId(messageRecord.getIndividualRecipient().getId(), messageRecord.getDateSent())));
ApplicationDependencies.getJobManager().add(new MultiDeviceViewOnceOpenJob(new MessageTable.SyncMessageId(messageRecord.getToRecipient().getId(), messageRecord.getDateSent())));
return tempUri;
} catch (IOException e) {
@ -1795,24 +1814,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onAddToContactsClicked(@NonNull Contact contactWithAvatar) {
if (getContext() != null) {
new AsyncTask<Void, Void, Intent>() {
@Override
protected Intent doInBackground(Void... voids) {
return ContactUtil.buildAddToContactsIntent(getContext(), contactWithAvatar);
}
@Override
protected void onPostExecute(Intent intent) {
try {
startActivityForResult(intent, CODE_ADD_EDIT_CONTACT);
} catch (ActivityNotFoundException e) {
Log.w(TAG, "Could not locate contacts activity", e);
Toast.makeText(requireContext(), R.string.ConversationFragment__contacts_app_not_found, Toast.LENGTH_SHORT).show();
}
}
}.execute();
if (getContext() == null) {
return;
}
disposables.add(AddToContactsContract.createIntentAndLaunch(
ConversationFragment.this,
addToContactsLauncher,
contactWithAvatar
));
}
@Override
@ -2062,6 +2072,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args), options.toBundle());
}
@Override
public void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord) {
if (messageRecord.isOutgoing()) {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getToRecipient().getId(), messageRecord.getId());
} else {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getFromRecipient().getId(), messageRecord.getId());
}
}
@Override
public void onActivatePaymentsClicked() {
Intent intent = new Intent(requireContext(), PaymentsActivity.class);
@ -2086,15 +2105,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == CODE_ADD_EDIT_CONTACT && getContext() != null) {
ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false));
}
}
private void handleEnterMultiSelect(@NonNull ConversationMessage conversationMessage) {
Set<MultiselectPart> multiselectParts = conversationMessage.getMultiselectCollection().toSet();
@ -2217,6 +2227,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
case REPLY:
handleReplyMessage(conversationMessage);
break;
case EDIT:
handleEditMessage(conversationMessage);
break;
case FORWARD:
handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet());
break;
@ -2318,34 +2331,4 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}, 400);
}
}
private static final class TransitionListener implements Animator.AnimatorListener {
private final ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f);
TransitionListener(RecyclerView recyclerView) {
animator.addUpdateListener(unused -> recyclerView.invalidate());
animator.setDuration(100L);
}
@Override
public void onAnimationStart(Animator animation) {
animator.start();
}
@Override
public void onAnimationEnd(Animator animation) {
animator.end();
}
@Override
public void onAnimationCancel(Animator animation) {
// Do Nothing
}
@Override
public void onAnimationRepeat(Animator animation) {
// Do Nothing
}
}
}

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
@ -8,9 +9,13 @@ import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@ -23,6 +28,7 @@ import java.util.List;
import java.util.Objects;
public class ConversationIntents {
private static final String TAG = Log.tag(ConversationIntents.class);
private static final String BUBBLE_AUTHORITY = "bubble";
private static final String NOTIFICATION_CUSTOM_SCHEME = "custom";
@ -38,6 +44,7 @@ public class ConversationIntents {
private static final String EXTRA_WITH_SEARCH_OPEN = "with_search_open";
private static final String EXTRA_GIFT_BADGE = "gift_badge";
private static final String EXTRA_SHARE_DATA_TIMESTAMP = "share_data_timestamp";
private static final String EXTRA_CONVERSATION_TYPE = "conversation_type";
private static final String INTENT_DATA = "intent_data";
private static final String INTENT_TYPE = "intent_type";
@ -94,21 +101,22 @@ public class ConversationIntents {
return uri != null && Objects.equals(uri.getScheme(), NOTIFICATION_CUSTOM_SCHEME);
}
final static class Args {
private final RecipientId recipientId;
private final long threadId;
private final String draftText;
private final ArrayList<Media> media;
private final StickerLocator stickerLocator;
private final boolean isBorderless;
private final int distributionType;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
private final boolean withSearchOpen;
private final Badge giftBadge;
private final long shareDataTimestamp;
public final static class Args {
private final RecipientId recipientId;
private final long threadId;
private final String draftText;
private final ArrayList<Media> media;
private final StickerLocator stickerLocator;
private final boolean isBorderless;
private final int distributionType;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
private final boolean withSearchOpen;
private final Badge giftBadge;
private final long shareDataTimestamp;
private final ConversationScreenType conversationScreenType;
static Args from(@NonNull Bundle arguments) {
public static Args from(@NonNull Bundle arguments) {
Uri intentDataUri = getIntentData(arguments);
if (isBubbleIntentUri(intentDataUri)) {
return new Args(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
@ -122,7 +130,8 @@ public class ConversationIntents {
false,
false,
null,
-1L);
-1L,
ConversationScreenType.BUBBLE);
}
return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
@ -136,7 +145,8 @@ public class ConversationIntents {
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
arguments.getParcelable(EXTRA_GIFT_BADGE),
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L));
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
}
private Args(@NonNull RecipientId recipientId,
@ -150,7 +160,8 @@ public class ConversationIntents {
boolean firstTimeInSelfCreatedGroup,
boolean withSearchOpen,
@Nullable Badge giftBadge,
long shareDataTimestamp)
long shareDataTimestamp,
@NonNull ConversationScreenType conversationScreenType)
{
this.recipientId = recipientId;
this.threadId = threadId;
@ -162,8 +173,9 @@ public class ConversationIntents {
this.startingPosition = startingPosition;
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
this.withSearchOpen = withSearchOpen;
this.giftBadge = giftBadge;
this.shareDataTimestamp = shareDataTimestamp;
this.giftBadge = giftBadge;
this.shareDataTimestamp = shareDataTimestamp;
this.conversationScreenType = conversationScreenType;
}
public @NonNull RecipientId getRecipientId() {
@ -221,43 +233,54 @@ public class ConversationIntents {
public long getShareDataTimestamp() {
return shareDataTimestamp;
}
public @NonNull ConversationScreenType getConversationScreenType() {
return conversationScreenType;
}
}
public final static class Builder {
private final Context context;
private final Class<? extends ConversationActivity> conversationActivityClass;
private final RecipientId recipientId;
private final long threadId;
private final Context context;
private final Class<? extends Activity> conversationActivityClass;
private final RecipientId recipientId;
private final long threadId;
private String draftText;
private List<Media> media;
private StickerLocator stickerLocator;
private boolean isBorderless;
private int distributionType = ThreadTable.DistributionTypes.DEFAULT;
private int startingPosition = -1;
private Uri dataUri;
private String dataType;
private boolean firstTimeInSelfCreatedGroup;
private boolean withSearchOpen;
private Badge giftBadge;
private long shareDataTimestamp = -1L;
private String draftText;
private List<Media> media;
private StickerLocator stickerLocator;
private boolean isBorderless;
private int distributionType = ThreadTable.DistributionTypes.DEFAULT;
private int startingPosition = -1;
private Uri dataUri;
private String dataType;
private boolean firstTimeInSelfCreatedGroup;
private boolean withSearchOpen;
private Badge giftBadge;
private long shareDataTimestamp = -1L;
private ConversationScreenType conversationScreenType;
private Builder(@NonNull Context context,
@NonNull RecipientId recipientId,
long threadId)
{
this(context, ConversationActivity.class, recipientId, threadId);
this(
context,
getBaseConversationActivity(),
recipientId,
threadId
);
}
private Builder(@NonNull Context context,
@NonNull Class<? extends ConversationActivity> conversationActivityClass,
@NonNull Class<? extends Activity> conversationActivityClass,
@NonNull RecipientId recipientId,
long threadId)
{
this.context = context;
this.conversationActivityClass = conversationActivityClass;
this.recipientId = recipientId;
this.threadId = threadId;
this.threadId = resolveThreadId(recipientId, threadId);
this.conversationScreenType = ConversationScreenType.fromActivityClass(conversationActivityClass);
}
public @NonNull Builder withDraftText(@Nullable String draftText) {
@ -309,7 +332,7 @@ public class ConversationIntents {
this.firstTimeInSelfCreatedGroup = true;
return this;
}
public Builder withGiftBadge(@NonNull Badge badge) {
this.giftBadge = badge;
return this;
@ -319,7 +342,7 @@ public class ConversationIntents {
this.shareDataTimestamp = timestamp;
return this;
}
public @NonNull Intent build() {
if (stickerLocator != null && media != null) {
throw new IllegalStateException("Cannot have both sticker and media array");
@ -347,6 +370,7 @@ public class ConversationIntents {
intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen);
intent.putExtra(EXTRA_GIFT_BADGE, giftBadge);
intent.putExtra(EXTRA_SHARE_DATA_TIMESTAMP, shareDataTimestamp);
intent.putExtra(EXTRA_CONVERSATION_TYPE, conversationScreenType.code);
if (draftText != null) {
intent.putExtra(EXTRA_TEXT, draftText);
@ -371,4 +395,62 @@ public class ConversationIntents {
return intent;
}
}
public enum ConversationScreenType {
NORMAL(0),
BUBBLE(1),
POPUP(2);
private final int code;
ConversationScreenType(int code) {
this.code = code;
}
public boolean isInBubble() {
return Objects.equals(this, BUBBLE);
}
public boolean isNormal() {
return Objects.equals(this, NORMAL);
}
private static @NonNull ConversationScreenType from(int code) {
for (ConversationScreenType type : values()) {
if (type.code == code) {
return type;
}
}
return NORMAL;
}
private static @NonNull ConversationScreenType fromActivityClass(Class<? extends Activity> activityClass) {
if (Objects.equals(activityClass, ConversationPopupActivity.class)) {
return POPUP;
} else if (Objects.equals(activityClass, BubbleConversationActivity.class)) {
return BUBBLE;
} else {
return NORMAL;
}
}
}
private static long resolveThreadId(@NonNull RecipientId recipientId, long threadId) {
if (threadId >= 0 && SignalStore.internalValues().useConversationFragmentV2()) {
Log.w(TAG, "Getting thread id from database...");
// TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this.
return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId));
} else {
return threadId;
}
}
private static Class<? extends Activity> getBaseConversationActivity() {
if (SignalStore.internalValues().useConversationFragmentV2()) {
return ConversationActivity.class;
} else {
return org.thoughtcrime.securesms.conversation.ConversationActivity.class;
}
}
}

View file

@ -179,7 +179,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Optional<MessageRecord> nextMessageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private LiveRecipient author;
private GlideRequests glideRequests;
private Optional<MessageRecord> previousMessage;
private ConversationItemDisplayMode displayMode;
@ -350,7 +350,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@NonNull Colorizer colorizer,
@NonNull ConversationItemDisplayMode displayMode)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.author != null) this.author.removeForeverObserver(this);
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
lastYDownRelativeToThis = 0;
@ -365,28 +365,28 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.batchSelected = batchSelected;
this.conversationRecipient = conversationRecipient.live();
this.groupThread = conversationRecipient.isGroup();
this.recipient = messageRecord.getIndividualRecipient().live();
this.author = messageRecord.getFromRecipient().live();
this.canPlayContent = false;
this.mediaItem = null;
this.colorizer = colorizer;
this.displayMode = displayMode;
this.previousMessage = previousMessageRecord;
this.recipient.observeForever(this);
this.author.observeForever(this);
this.conversationRecipient.observeForever(this);
setGutterSizes(messageRecord, groupThread);
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, allowedToPlayInline);
setBodyText(messageRecord, searchQuery, isMessageRequestAccepted);
setBubbleState(messageRecord, messageRecord.getRecipient(), hasWallpaper, colorizer);
setBubbleState(messageRecord, messageRecord.getFromRecipient(), hasWallpaper, colorizer);
setInteractionState(conversationMessage, pulse);
setStatusIcons(messageRecord, hasWallpaper);
setContactPhoto(recipient.get());
setGroupMessageStatus(messageRecord, recipient.get());
setContactPhoto(author.get());
setGroupMessageStatus(messageRecord, author.get());
setGroupAuthorColor(messageRecord, hasWallpaper, colorizer);
setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper);
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, messageRecord.getRecipient().getChatColors());
setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setReactions(messageRecord);
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
@ -406,7 +406,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void updateTimestamps() {
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale);
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale, displayMode);
}
@Override
@ -513,10 +513,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
!messageRecord.isRemoteDelete() &&
bodyText.getLastLineWidth() > 0)
{
TextView dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
View dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
if (bodyText.isSingleLine() && !messageRecord.isFailed()) {
int maxBubbleWidth = hasBigImageLinkPreview(messageRecord) || hasThumbnail(messageRecord) ? readDimen(R.dimen.media_bubble_max_width) : getMaxBubbleWidth();
@ -614,7 +614,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
if (recipient.getId().equals(modified.getId())) {
if (author.getId().equals(modified.getId())) {
setContactPhoto(modified);
setGroupMessageStatus(messageRecord, modified);
}
@ -656,8 +656,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void unbind() {
if (recipient != null) {
recipient.removeForeverObserver(this);
if (author != null) {
author.removeForeverObserver(this);
}
if (conversationRecipient != null) {
conversationRecipient.removeForeverObserver(this);
@ -1291,7 +1291,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
MediaMmsMessageRecord mediaMmsMessageRecord = (MediaMmsMessageRecord) messageRecord;
paymentViewStub.setVisibility(View.VISIBLE);
paymentViewStub.get().bindPayment(messageRecord.getIndividualRecipient(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
paymentViewStub.get().bindPayment(messageRecord.getFromRecipient(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
footer.setVisibility(VISIBLE);
} else {
@ -1516,7 +1516,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread, @NonNull ChatColors chatColors) {
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread);
if (hasQuote(messageRecord)) {
if (quoteView == null) {
@ -1644,7 +1644,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (isFooterVisible(current, next, isGroupThread)) {
ConversationItemFooter activeFooter = getActiveFooter(current);
activeFooter.setVisibility(VISIBLE);
activeFooter.setMessageRecord(current, locale);
activeFooter.setMessageRecord(current, locale, displayMode);
if (MessageRecordUtil.isEditMessage(current)) {
activeFooter.getDateView().setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onEditedIndicatorClicked(current);
}
});
} else {
activeFooter.getDateView().setOnClickListener(null);
activeFooter.getDateView().setClickable(false);
}
if (hasWallpaper && hasNoBubble((messageRecord))) {
if (messageRecord.isOutgoing()) {
@ -1692,7 +1703,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setHasBeenQuoted(@NonNull ConversationMessage message) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty()) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty() && displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED) {
quotedIndicator.setVisibility(VISIBLE);
quotedIndicator.setOnClickListener(quotedIndicatorClickListener);
} else if (quotedIndicator != null) {
@ -1715,7 +1726,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
return hasAudio(messageRecord);
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord);
}
private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) {
@ -1751,7 +1762,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord, boolean hasWallpaper, @NonNull Colorizer colorizer) {
if (groupSender != null) {
groupSender.setTextColor(colorizer.getIncomingGroupSenderColor(getContext(), messageRecord.getIndividualRecipient()));
groupSender.setTextColor(colorizer.getIncomingGroupSenderColor(getContext(), messageRecord.getFromRecipient()));
}
}
@ -1760,7 +1771,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (isGroupThread && !current.isOutgoing()) {
contactPhotoHolder.setVisibility(VISIBLE);
if (!previous.isPresent() || previous.get().isUpdate() || !current.getRecipient().equals(previous.get().getRecipient()) ||
if (!previous.isPresent() || previous.get().isUpdate() || !current.getFromRecipient().equals(previous.get().getFromRecipient()) ||
!DateUtils.isSameDay(previous.get().getTimestamp(), current.getTimestamp()) || !isWithinClusteringTime(current, previous.get()))
{
groupSenderHolder.setVisibility(VISIBLE);
@ -1775,7 +1786,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
groupSenderHolder.setVisibility(GONE);
}
if (!next.isPresent() || next.get().isUpdate() || !current.getRecipient().equals(next.get().getRecipient()) || !isWithinClusteringTime(current, next.get())) {
if (!next.isPresent() || next.get().isUpdate() || !current.getFromRecipient().equals(next.get().getFromRecipient()) || !isWithinClusteringTime(current, next.get())) {
contactPhoto.setVisibility(VISIBLE);
badgeImageView.setVisibility(VISIBLE);
} else {
@ -1819,7 +1830,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int background;
if (isSingularMessage(current, previous, next, isGroupThread)) {
if (isSingularMessage(current, previous, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_alone;
outliner.setRadius(bigRadius);
@ -1875,7 +1886,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
if (isGroupThread) {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current);
!current.getFromRecipient().equals(previous.get().getFromRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current);
} else {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get()) ||
@ -1886,7 +1897,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (isGroupThread) {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) ||
!current.getFromRecipient().equals(next.get().getFromRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) ||
MessageRecordUtil.isScheduled(current);
} else {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
@ -1900,6 +1911,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isFooterVisible(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
return false;
}
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(next.get().getTimestamp(), current.getTimestamp());
return forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() ||
@ -1915,11 +1930,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse);
int spacingBottom = spacingTop;
if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (isStartOfMessageCluster(current, previous, isGroupThread) && (displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED || next.isEmpty())) {
spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}
if (isEndOfMessageCluster(current, next, isGroupThread)) {
if (isEndOfMessageCluster(current, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
spacingBottom = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}
@ -2350,13 +2365,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (slide instanceof ImageSlide) {
failedMessage = messageRecord.isOutgoing() ? context.getString(R.string.ConversationItem_cant_download_image_you_will_need_to_send_it_again)
: context.getString(R.string.ConversationItem_cant_download_image_s_will_need_to_send_it_again, messageRecord.getIndividualRecipient().getShortDisplayName(context));
: context.getString(R.string.ConversationItem_cant_download_image_s_will_need_to_send_it_again, messageRecord.getFromRecipient().getShortDisplayName(context));
} else if (slide instanceof VideoSlide) {
failedMessage = messageRecord.isOutgoing() ? context.getString(R.string.ConversationItem_cant_download_video_you_will_need_to_send_it_again)
: context.getString(R.string.ConversationItem_cant_download_video_s_will_need_to_send_it_again, messageRecord.getIndividualRecipient().getShortDisplayName(context));
: context.getString(R.string.ConversationItem_cant_download_video_s_will_need_to_send_it_again, messageRecord.getFromRecipient().getShortDisplayName(context));
} else {
failedMessage = messageRecord.isOutgoing() ? context.getString(R.string.ConversationItem_cant_download_message_you_will_need_to_send_it_again)
: context.getString(R.string.ConversationItem_cant_download_message_s_will_need_to_send_it_again, messageRecord.getIndividualRecipient().getShortDisplayName(context));
: context.getString(R.string.ConversationItem_cant_download_message_s_will_need_to_send_it_again, messageRecord.getFromRecipient().getShortDisplayName(context));
}
new MaterialAlertDialogBuilder(getContext())
@ -2405,7 +2420,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
} else if (!messageRecord.isOutgoing() && messageRecord.isIdentityMismatchFailure()) {
if (eventListener != null) {
eventListener.onIncomingIdentityMismatchClicked(messageRecord.getIndividualRecipient().getId());
eventListener.onIncomingIdentityMismatchClicked(messageRecord.getFromRecipient().getId());
}
} else if (messageRecord.isPendingInsecureSmsFallback()) {
handleMessageApproval();

View file

@ -7,6 +7,13 @@ enum class ConversationItemDisplayMode {
/** Smaller bubbles, often trimming text and shrinking images. Used for quote threads. */
CONDENSED,
/** Smaller bubbles, no footers */
EXTRA_CONDENSED,
/** Less length restrictions. Used to show more info in message details. */
DETAILED
DETAILED;
fun displayWallpaper(): Boolean {
return this == STANDARD || this == DETAILED
}
}

View file

@ -274,12 +274,14 @@ import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@ -390,6 +392,7 @@ public class ConversationParentFragment extends Fragment
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private ImageButton attachButton;
private ImageButton sendEditButton;
protected ConversationTitleView titleView;
private TextView charactersLeft;
private ConversationFragment fragment;
@ -778,7 +781,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
result.getScheduledTime(),
null).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
@ -1533,6 +1537,7 @@ public class ConversationParentFragment extends Fragment
inputPanel.setEnabled(canSendMessages);
sendButton.setEnabled(canSendMessages);
attachButton.setEnabled(canSendMessages);
sendEditButton.setEnabled(canSendMessages);
});
}
@ -1612,6 +1617,24 @@ public class ConversationParentFragment extends Fragment
quoteResult.addListener(listener);
break;
case Draft.MESSAGE_EDIT:
SettableFuture<Boolean> messageEditResult = new SettableFuture<>();
disposables.add(draftViewModel.loadDraftEditMessage(draft.getValue()).subscribe(
conversationMessage -> {
inputPanel.enterEditMessageMode(glideRequests, conversationMessage, true);
messageEditResult.set(true);
},
err -> {
Log.e(TAG, "Failed to restore message edit from a draft.", err);
messageEditResult.set(false);
},
() -> {
Log.e(TAG, "Failed to load message edit. No matching message record.");
messageEditResult.set(false);
}
));
messageEditResult.addListener(listener);
break;
case Draft.VOICE_NOTE:
case Draft.BODY_RANGES:
listener.onSuccess(true);
@ -1763,6 +1786,7 @@ public class ConversationParentFragment extends Fragment
buttonToggle = view.findViewById(R.id.button_toggle);
sendButton = view.findViewById(R.id.send_button);
attachButton = view.findViewById(R.id.attach_button);
sendEditButton = view.findViewById(R.id.send_edit_button);
composeText = view.findViewById(R.id.embedded_text_editor);
charactersLeft = view.findViewById(R.id.space_left);
emojiDrawerStub = ViewUtil.findStubById(view, R.id.emoji_drawer_stub);
@ -1818,6 +1842,7 @@ public class ConversationParentFragment extends Fragment
attachButton.setOnClickListener(new AttachButtonListener());
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
sendButton.setOnClickListener(sendButtonListener);
sendEditButton.setOnClickListener(v -> handleSendEditMessage());
sendButton.setScheduledSendListener(new SendButton.ScheduledSendListener() {
@Override
public void onSendScheduled() {
@ -2646,6 +2671,8 @@ public class ConversationParentFragment extends Fragment
callback.onSendComplete(threadId);
draftViewModel.onSendComplete(threadId);
inputPanel.exitEditMessageMode();
}
private void sendMessage(@Nullable String metricId) {
@ -2681,6 +2708,7 @@ public class ConversationParentFragment extends Fragment
MessageSendType sendType = sendButton.getSelectedSendType();
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
boolean initiating = threadId == -1;
boolean isEditMessage = inputPanel.inEditMessageMode();
boolean needsSplit = !sendType.usesSmsTransport() && message.length() > sendType.calculateCharacters(message).maxPrimaryMessageSize;
boolean isMediaMessage = attachmentManager.isAttachmentPresent() ||
recipient.isGroup() ||
@ -2693,12 +2721,17 @@ public class ConversationParentFragment extends Fragment
Log.i(TAG, "[sendMessage] recipient: " + recipient.getId() + ", threadId: " + threadId + ", sendType: " + (sendType.usesSignalTransport() ? "signal" : "sms") + ", isManual: " + sendButton.isManualSelection());
if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
if (!sendType.usesSignalTransport() && isEditMessage) {
Toast.makeText(requireContext(),
R.string.ConversationActivity_edit_sms_message_error,
Toast.LENGTH_LONG)
.show();
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
handleRecentSafetyNumberChange();
} else if (isMediaMessage) {
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
} else {
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
}
} catch (RecipientFormattingException ex) {
Toast.makeText(requireContext(),
@ -2742,7 +2775,8 @@ public class ConversationParentFragment extends Fragment
null,
true,
result.getBodyRanges(),
-1);
-1,
0);
final Context context = requireContext().getApplicationContext();
@ -2764,7 +2798,7 @@ public class ConversationParentFragment extends Fragment
}, this::sendComplete);
}
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate, @Nullable MessageId editMessageId)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@ -2783,7 +2817,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
metricId,
scheduledDate);
scheduledDate,
editMessageId);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@ -2801,7 +2836,7 @@ public class ConversationParentFragment extends Fragment
final boolean clearComposeBox,
final @Nullable String metricId)
{
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1, null);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@ -2818,11 +2853,12 @@ public class ConversationParentFragment extends Fragment
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId,
final long scheduledDate)
final long scheduledDate,
@Nullable MessageId editMessageId)
{
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && styling != null && styling.getRangesCount() > 0) {
final String finalBody = body;
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate));
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate, editMessageId));
return new SettableFuture<>(null);
}
@ -2858,7 +2894,8 @@ public class ConversationParentFragment extends Fragment
null,
false,
styling,
scheduledDate);
scheduledDate,
editMessageId != null ? editMessageId.getId() : 0);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();
@ -2898,7 +2935,12 @@ public class ConversationParentFragment extends Fragment
return future;
}
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
private void sendTextMessage(@NonNull MessageSendType sendType,
final long expiresIn,
final boolean initiating,
final @Nullable String metricId,
long scheduledDate,
@Nullable MessageId messageToEdit)
throws InvalidMessageException
{
final long thread = this.threadId;
@ -2909,8 +2951,11 @@ public class ConversationParentFragment extends Fragment
final OutgoingMessage message;
if (sendPush) {
if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate);
if (messageToEdit != null) {
message = OutgoingMessage.editText(recipient.get(), messageBody, System.currentTimeMillis(), null, messageToEdit.getId());
} else if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null)
.sendAt(scheduledDate);
} else {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
}
@ -2942,6 +2987,13 @@ public class ConversationParentFragment extends Fragment
return;
}
if (inputPanel.inEditMessageMode()) {
buttonToggle.display(sendEditButton);
quickAttachmentToggle.hide();
inlineAttachmentToggle.hide();
return;
}
if (draftViewModel.getVoiceNoteDraft() != null) {
buttonToggle.display(sendButton);
quickAttachmentToggle.hide();
@ -3167,7 +3219,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
scheduledDate);
scheduledDate,
null);
}
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
@ -3484,7 +3537,11 @@ public class ConversationParentFragment extends Fragment
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_SEND) {
sendButton.performClick();
if (inputPanel.isInEditMode()) {
sendEditButton.performClick();
} else {
sendButton.performClick();
}
return true;
}
return false;
@ -3568,7 +3625,13 @@ public class ConversationParentFragment extends Fragment
}
private void handleSaveDraftOnTextChange(@NonNull CharSequence text) {
textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text)));
textDraftSaveDebouncer.publish(() -> {
if (inputPanel.inEditMessageMode()) {
draftViewModel.setMessageEditDraft(inputPanel.getEditMessageId(), StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
} else {
draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
}
});
}
private void handleTypingIndicatorOnTextChange(@NonNull String text) {
@ -3825,16 +3888,13 @@ public class ConversationParentFragment extends Fragment
if (isSearchRequested) {
searchViewItem.collapseActionView();
}
if (inputPanel.inEditMessageMode()) {
inputPanel.exitEditMessageMode();
}
MessageRecord messageRecord = conversationMessage.getMessageRecord();
Recipient author;
if (messageRecord.isOutgoing()) {
author = Recipient.self();
} else {
author = messageRecord.getIndividualRecipient();
}
Recipient author = messageRecord.getFromRecipient();
if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getSharedContacts().isEmpty()) {
Contact contact = ((MmsMessageRecord) messageRecord).getSharedContacts().get(0);
@ -3887,6 +3947,63 @@ public class ConversationParentFragment extends Fragment
inputPanel.clickOnComposeInput();
}
@Override
public void handleEditMessage(@NonNull ConversationMessage conversationMessage) {
if (!FeatureFlags.editMessageSending()) {
return;
}
if (isSearchRequested) {
searchViewItem.collapseActionView();
}
disposables.add(viewModel.resolveMessageToEdit(conversationMessage).subscribe(updatedMessage -> {
inputPanel.enterEditMessageMode(glideRequests, updatedMessage, false);
}));
}
private void handleSendEditMessage() {
if (!FeatureFlags.editMessageSending()) {
Log.w(TAG, "Edit message sending disabled, forcing exit of edit mode");
inputPanel.exitEditMessageMode();
return;
}
if (!inputPanel.inEditMessageMode()) {
Log.w(TAG, "Not in edit message mode, unknown state, forcing re-exit");
inputPanel.exitEditMessageMode();
return;
}
MessageRecord editMessage = inputPanel.getEditMessage();
if (editMessage == null) {
Log.w(TAG, "No edit message found, forcing exit");
inputPanel.exitEditMessageMode();
return;
}
if (!MessageConstraintsUtil.isValidEditMessageSend(editMessage, System.currentTimeMillis())) {
Log.i(TAG, "Edit message no longer valid");
final int editDurationHours = MessageConstraintsUtil.getEditMessageThresholdHours();
Dialogs.showAlertDialog(requireContext(), null, getResources().getQuantityString(R.plurals.ConversationActivity_edit_message_too_old, editDurationHours, editDurationHours));
return;
}
String metricId = recipient.get().isGroup() ? SignalLocalMetrics.GroupMessageSend.start()
: SignalLocalMetrics.IndividualMessageSend.start();
sendMessage(metricId);
}
@Override
public void onEnterEditMode() {
updateToggleButtonState();
}
@Override
public void onExitEditMode() {
updateToggleButtonState();
draftViewModel.deleteMessageEditDraft();
}
@Override
public void onMessageActionToolbarOpened() {
searchViewItem.collapseActionView();

View file

@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@ -738,6 +739,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_reply_24, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
}
if (FeatureFlags.editMessageSending() && menuState.shouldShowEditAction()) {
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> handleActionItemClicked(Action.EDIT)));
}
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD)));
}
@ -968,6 +973,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
public enum Action {
REPLY,
EDIT,
FORWARD,
RESEND,
DOWNLOAD,

View file

@ -6,6 +6,7 @@ import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
@ -15,32 +16,38 @@ import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.BubbleUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
class ConversationRepository {
public class ConversationRepository {
private static final String TAG = Log.tag(ConversationRepository.class);
private final Context context;
ConversationRepository() {
public ConversationRepository() {
this.context = ApplicationDependencies.getApplication();
}
@ -113,7 +120,7 @@ class ConversationRepository {
return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate);
}
void markGiftBadgeRevealed(long messageId) {
public void markGiftBadgeRevealed(long messageId) {
SignalExecutors.BOUNDED_IO.execute(() -> {
List<MessageTable.MarkedMessageInfo> markedMessageInfo = SignalDatabase.messages().setOutgoingGiftsRevealed(Collections.singletonList(messageId));
if (!markedMessageInfo.isEmpty()) {
@ -173,6 +180,28 @@ class ConversationRepository {
}).subscribeOn(Schedulers.io());
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return Single.fromCallable(() -> {
MessageRecord messageRecord = message.getMessageRecord();
if (MessageRecordUtil.hasTextSlide(messageRecord)) {
TextSlide textSlide = MessageRecordUtil.requireTextSlide(messageRecord);
if (textSlide.getUri() == null) {
return message;
}
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
String body = StreamUtil.readFullyAsString(stream);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body);
} catch (IOException e) {
Log.w(TAG, "Failed to read text slide data.");
}
}
return message;
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
Observable<Integer> getUnreadCount(long threadId, long afterTime) {
if (threadId <= -1L || afterTime <= 0L) {
return Observable.just(0);

View file

@ -162,10 +162,10 @@ public final class ConversationUpdateItem extends FrameLayout
this.conversationRecipient = conversationRecipient;
this.isMessageRequestAccepted = isMessageRequestAccepted;
senderObserver.observe(lifecycleOwner, messageRecord.getIndividualRecipient());
senderObserver.observe(lifecycleOwner, messageRecord.getFromRecipient());
if (conversationRecipient.isActiveGroup() &&
(messageRecord.isGroupCall() || messageRecord.isCollapsedGroupV2JoinUpdate() || messageRecord.isGroupV2JoinRequest(messageRecord.getIndividualRecipient().getServiceId().orElse(null)))) {
(messageRecord.isGroupCall() || messageRecord.isCollapsedGroupV2JoinUpdate() || messageRecord.isGroupV2JoinRequest(messageRecord.getFromRecipient().getServiceId().orElse(null)))) {
groupObserver.observe(lifecycleOwner, conversationRecipient);
groupData.observe(lifecycleOwner, conversationRecipient);
} else {
@ -446,7 +446,7 @@ public final class ConversationUpdateItem extends FrameLayout
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onSafetyNumberLearnMoreClicked(conversationMessage.getMessageRecord().getIndividualRecipient());
eventListener.onSafetyNumberLearnMoreClicked(conversationMessage.getMessageRecord().getFromRecipient());
}
});
} else if (conversationMessage.getMessageRecord().isGroupCall()) {
@ -519,15 +519,15 @@ public final class ConversationUpdateItem extends FrameLayout
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onBadDecryptLearnMoreClicked(conversationMessage.getMessageRecord().getRecipient().getId());
eventListener.onBadDecryptLearnMoreClicked(conversationMessage.getMessageRecord().getFromRecipient().getId());
}
});
} else if (conversationMessage.getMessageRecord().isChangeNumber() && conversationMessage.getMessageRecord().getIndividualRecipient().isSystemContact()) {
} else if (conversationMessage.getMessageRecord().isChangeNumber() && conversationMessage.getMessageRecord().getFromRecipient().isSystemContact()) {
actionButton.setText(R.string.ConversationUpdateItem_update_contact);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onChangeNumberUpdateContact(conversationMessage.getMessageRecord().getIndividualRecipient());
eventListener.onChangeNumberUpdateContact(conversationMessage.getMessageRecord().getFromRecipient());
}
});
} else if (shouldShowBlockRequestAction(conversationMessage.getMessageRecord())) {
@ -535,7 +535,7 @@ public final class ConversationUpdateItem extends FrameLayout
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onBlockJoinRequest(conversationMessage.getMessageRecord().getIndividualRecipient());
eventListener.onBlockJoinRequest(conversationMessage.getMessageRecord().getFromRecipient());
}
});
} else if (conversationMessage.getMessageRecord().isPaymentsRequestToActivate() && !conversationMessage.getMessageRecord().isOutgoing() && !SignalStore.paymentsValues().mobileCoinPaymentsEnabled()) {
@ -551,7 +551,7 @@ public final class ConversationUpdateItem extends FrameLayout
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onSendPaymentClicked(conversationMessage.getMessageRecord().getIndividualRecipient().getId());
eventListener.onSendPaymentClicked(conversationMessage.getMessageRecord().getFromRecipient().getId());
}
});
// MOLLY: Ensure presentTimer() is called above for call logs
@ -573,7 +573,7 @@ public final class ConversationUpdateItem extends FrameLayout
}
private boolean shouldShowBlockRequestAction(MessageRecord messageRecord) {
Recipient toBlock = messageRecord.getIndividualRecipient();
Recipient toBlock = messageRecord.getFromRecipient();
if (!toBlock.hasServiceId() || !groupData.isSelfAdmin() || groupData.isBanned(toBlock) || groupData.isFullMember(toBlock)) {
return false;

View file

@ -52,12 +52,12 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.processors.PublishProcessor;
@ -425,10 +425,15 @@ public class ConversationViewModel extends ViewModel {
}
@NonNull LiveData<Optional<NotificationProfile>> getActiveNotificationProfile() {
final Observable<Optional<NotificationProfile>> activeProfile = Observable.combineLatest(Observable.interval(0, 30, TimeUnit.SECONDS), notificationProfilesRepository.getProfiles(), (interval, profiles) -> profiles)
.map(profiles -> Optional.ofNullable(NotificationProfiles.getActiveProfile(profiles)));
Flowable<Optional<NotificationProfile>> activeProfile = notificationProfilesRepository.getProfiles()
.map(profiles -> Optional.ofNullable(NotificationProfiles.getActiveProfile(profiles)));
return LiveDataReactiveStreams.fromPublisher(activeProfile.toFlowable(BackpressureStrategy.LATEST));
return LiveDataReactiveStreams.fromPublisher(activeProfile);
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return conversationRepository.resolveMessageToEdit(message);
}
void setArgs(@NonNull ConversationIntents.Args args) {

View file

@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import java.util.Set;
import java.util.stream.Collectors;
@ -25,6 +26,7 @@ final class MenuState {
private final boolean delete;
private final boolean reactions;
private final boolean paymentDetails;
private final boolean edit;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@ -36,6 +38,7 @@ final class MenuState {
delete = builder.delete;
reactions = builder.reactions;
paymentDetails = builder.paymentDetails;
edit = builder.edit;
}
boolean shouldShowForwardAction() {
@ -74,6 +77,10 @@ final class MenuState {
return paymentDetails;
}
boolean shouldShowEditAction() {
return edit;
}
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
@ -152,7 +159,8 @@ final class MenuState {
.shouldShowReplyAction(false)
.shouldShowDetailsAction(false)
.shouldShowSaveAttachmentAction(false)
.shouldShowResendAction(false);
.shouldShowResendAction(false)
.shouldShowEdit(false);
} else {
MessageRecord messageRecord = selectedParts.iterator().next().getMessageRecord();
@ -169,6 +177,10 @@ final class MenuState {
.shouldShowForwardAction(shouldShowForwardAction)
.shouldShowDetailsAction(!actionMessage && !conversationRecipient.isReleaseNotes())
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
builder.shouldShowEdit(!actionMessage &&
hasText &&
MessageConstraintsUtil.isValidEditMessageSend(messageRecord, System.currentTimeMillis()));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment)
@ -191,36 +203,20 @@ final class MenuState {
boolean isDisplayingMessageRequest,
boolean isNonAdminInAnnouncementGroup)
{
return !actionMessage &&
!isNonAdminInAnnouncementGroup &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isPending() &&
!messageRecord.isFailed() &&
!isDisplayingMessageRequest &&
messageRecord.isSecure() &&
return !actionMessage &&
!isNonAdminInAnnouncementGroup &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isPending() &&
!messageRecord.isFailed() &&
!isDisplayingMessageRequest &&
messageRecord.isSecure() &&
(!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) &&
!messageRecord.getRecipient().isBlocked() &&
!messageRecord.getFromRecipient().isBlocked() &&
!conversationRecipient.isReleaseNotes();
}
static boolean isActionMessage(@NonNull MessageRecord messageRecord) {
return messageRecord.isGroupAction() ||
messageRecord.isCallLog() ||
messageRecord.isJoined() ||
messageRecord.isExpirationTimerUpdate() ||
messageRecord.isEndSession() ||
messageRecord.isIdentityUpdate() ||
messageRecord.isIdentityVerified() ||
messageRecord.isIdentityDefault() ||
messageRecord.isProfileChange() ||
messageRecord.isGroupV1MigrationEvent() ||
messageRecord.isChatSessionRefresh() ||
messageRecord.isInMemoryMessageRecord() ||
messageRecord.isChangeNumber() ||
messageRecord.isBoostRequest() ||
messageRecord.isPaymentsRequestToActivate() ||
messageRecord.isPaymentsActivated() ||
messageRecord.isSmsExportType();
return messageRecord.isInMemoryMessageRecord() || messageRecord.isUpdate();
}
private final static class Builder {
@ -234,6 +230,7 @@ final class MenuState {
private boolean delete;
private boolean reactions;
private boolean paymentDetails;
private boolean edit;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@ -280,6 +277,11 @@ final class MenuState {
return this;
}
@NonNull Builder shouldShowEdit(boolean edit) {
this.edit = edit;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);

View file

@ -93,7 +93,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
setScheduledMessagesMode(true)
}
@ -275,6 +275,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
}
companion object {

View file

@ -7,7 +7,9 @@ import android.text.SpannableString
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
@ -21,14 +23,19 @@ import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.adjustBodyRanges
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.concurrent.Executor
class DraftRepository(
@ -38,6 +45,10 @@ class DraftRepository(
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
) {
companion object {
val TAG = Log.tag(DraftRepository::class.java)
}
fun deleteVoiceNoteDraftData(draft: DraftTable.Draft?) {
if (draft != null) {
SignalExecutors.BOUNDED.execute {
@ -56,7 +67,11 @@ class DraftRepository(
}
draftTable.replaceDrafts(actualThreadId, drafts)
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
if (drafts.shouldUpdateSnippet()) {
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
} else {
threadTable.update(actualThreadId, unarchive = false, allowDeletion = false)
}
} else if (threadId > 0) {
draftTable.clearDrafts(threadId)
threadTable.update(threadId, unarchive = false, allowDeletion = false)
@ -101,5 +116,26 @@ class DraftRepository(
}
}
fun loadDraftMessageEdit(serialized: String): Maybe<ConversationMessage> {
return Maybe.fromCallable {
val messageId = MessageId.deserialize(serialized)
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return@fromCallable null
if (messageRecord.hasTextSlide()) {
val textSlide = messageRecord.requireTextSlide()
if (textSlide.uri != null) {
try {
PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body)
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load text slide", e)
}
}
}
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord)
}
}
data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?)
}

View file

@ -17,7 +17,8 @@ data class DraftState(
val bodyRangesDraft: DraftTable.Draft? = null,
val quoteDraft: DraftTable.Draft? = null,
val locationDraft: DraftTable.Draft? = null,
val voiceNoteDraft: DraftTable.Draft? = null
val voiceNoteDraft: DraftTable.Draft? = null,
val messageEditDraft: DraftTable.Draft? = null
) {
fun copyAndClearDrafts(threadId: Long = this.threadId): DraftState {
@ -26,6 +27,7 @@ data class DraftState(
fun toDrafts(): Drafts {
return Drafts().apply {
addIfNotNull(messageEditDraft)
addIfNotNull(textDraft)
addIfNotNull(bodyRangesDraft)
addIfNotNull(quoteDraft)
@ -41,7 +43,8 @@ data class DraftState(
bodyRangesDraft = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES),
quoteDraft = drafts.getDraftOfType(DraftTable.Draft.QUOTE),
locationDraft = drafts.getDraftOfType(DraftTable.Draft.LOCATION),
voiceNoteDraft = drafts.getDraftOfType(DraftTable.Draft.VOICE_NOTE)
voiceNoteDraft = drafts.getDraftOfType(DraftTable.Draft.VOICE_NOTE),
messageEditDraft = drafts.getDraftOfType(DraftTable.Draft.MESSAGE_EDIT)
)
}
}

View file

@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DraftTable.Draft
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.recipients.Recipient
@ -67,6 +68,28 @@ class DraftViewModel @JvmOverloads constructor(
store.update { it.copy(recipientId = recipient.id) }
}
fun setMessageEditDraft(messageId: MessageId, text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
val bodyRanges: BodyRangeList? = if (styleBodyRanges == null) {
mentionRanges
} else if (mentionRanges == null) {
styleBodyRanges
} else {
styleBodyRanges.toBuilder().addAllRanges(mentionRanges.rangesList).build()
}
saveDrafts(it.copy(textDraft = text.toTextDraft(), bodyRangesDraft = bodyRanges?.toDraft(), messageEditDraft = Draft(Draft.MESSAGE_EDIT, messageId.serialize())))
}
}
fun deleteMessageEditDraft() {
store.update {
saveDrafts(it.copy(textDraft = null, bodyRangesDraft = null, messageEditDraft = null))
}
}
fun setTextDraft(text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
@ -131,6 +154,12 @@ class DraftViewModel @JvmOverloads constructor(
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun loadDraftEditMessage(serialized: String): Maybe<ConversationMessage> {
return repository.loadDraftMessageEdit(serialized)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}
private fun String.toTextDraft(): Draft? {

View file

@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialog
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
@ -72,7 +73,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
}
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.quotes_list).apply {
@ -245,6 +246,11 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
dismiss()
getAdapterListener().onSendPaymentClicked(recipientId)
}
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) {
dismiss()
getAdapterListener().onEditedIndicatorClicked(messageRecord)
}
}
companion object {

View file

@ -0,0 +1,160 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.databinding.MessageEditHistoryBottomSheetBinding
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.ViewModelFactory
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.util.Locale
/**
* Show history of edits for a specific message.
*/
class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.4f
private val binding: MessageEditHistoryBottomSheetBinding by ViewBinderDelegate(MessageEditHistoryBottomSheetBinding::bind)
private val messageId: Long by lazy { requireArguments().getLong(ARGUMENT_MESSAGE_ID) }
private val conversationRecipient: Recipient by lazy { Recipient.resolved(requireArguments().getParcelable(ARGUMENT_CONVERSATION_RECIPIENT_ID)!!) }
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(messageId, conversationRecipient) })
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = MessageEditHistoryBottomSheetBinding.inflate(inflater, container, false).root
view.minimumHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
disposables.bindTo(viewLifecycleOwner)
val colorizer = Colorizer()
val messageAdapter = ConversationAdapter(
requireContext(),
viewLifecycleOwner,
GlideApp.with(this),
Locale.getDefault(),
ConversationAdapterListener(),
conversationRecipient,
colorizer
).apply {
setCondensedMode(ConversationItemDisplayMode.EXTRA_CONDENSED)
}
binding.editHistoryList.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
adapter = messageAdapter
itemAnimator = null
}
val recyclerViewColorizer = RecyclerViewColorizer(binding.editHistoryList)
disposables += viewModel
.getEditHistory()
.subscribeBy { messages ->
if (messages.isEmpty()) {
dismiss()
}
messageAdapter.submitList(messages)
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
disposables += viewModel.getNameColorsMap().subscribe { map ->
colorizer.onNameColorsChanged(map)
messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapter.PAYLOAD_NAME_COLORS)
}
initializeGiphyMp4()
}
private fun initializeGiphyMp4(): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
binding.videoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(binding.editHistoryList, callback, maxPlayback)
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
return callback
}
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by requireListener<ConversationBottomSheetCallback>().getConversationAdapterListener() {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onItemClick(item: MultiselectPart) = Unit
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
}
companion object {
private const val ARGUMENT_MESSAGE_ID = "message_id"
private const val ARGUMENT_CONVERSATION_RECIPIENT_ID = "recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageId: Long) {
EditMessageHistoryDialog()
.apply {
arguments = bundleOf(
ARGUMENT_MESSAGE_ID to messageId,
ARGUMENT_CONVERSATION_RECIPIENT_ID to threadRecipient
)
}
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View file

@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationDataSource
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
object EditMessageHistoryRepository {
fun getEditHistory(messageId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter ->
val threadId: Long = SignalDatabase.messages.getThreadIdForMessage(messageId)
if (threadId < 0) {
emitter.onNext(emptyList())
return@create
}
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(getEditHistorySync(messageId)) }
databaseObserver.registerConversationObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(getEditHistorySync(messageId))
}.subscribeOn(Schedulers.io())
}
private fun getEditHistorySync(messageId: Long): List<ConversationMessage> {
val context = ApplicationDependencies.getApplication()
val records = SignalDatabase
.messages
.getMessageEditHistory(messageId)
.toList()
val attachmentHelper = ConversationDataSource.AttachmentHelper()
.apply {
addAll(records)
fetchAttachments()
}
return attachmentHelper
.buildUpdatedModels(context, records)
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
}
}

View file

@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model to show history of edits for a specific message.
*/
class EditMessageHistoryViewModel(private val messageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
fun getEditHistory(): Observable<List<ConversationMessage>> {
return EditMessageHistoryRepository
.getEditHistory(messageId)
.observeOn(AndroidSchedulers.mainThread())
}
fun getNameColorsMap(): Observable<Map<RecipientId, NameColor>> {
return conversationRecipient
.live()
.observable()
.map { recipient ->
if (recipient.groupId.isPresent) {
groupAuthorNameColorHelper.getColorMap(recipient.groupId.get())
} else {
emptyMap()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}

View file

@ -17,7 +17,6 @@ import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.storage.SignalIdentityKeyStore;
import org.thoughtcrime.securesms.database.IdentityTable;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
@ -203,7 +202,7 @@ public final class SafetyNumberChangeRepository {
if (messageRecord.isMms()) {
SignalDatabase.messages().removeMismatchedIdentity(messageRecord.getId(), id, identityKey);
if (messageRecord.getRecipient().isDistributionList() || messageRecord.getRecipient().isPushGroup()) {
if (messageRecord.getToRecipient().isDistributionList() || messageRecord.getToRecipient().isPushGroup()) {
resendIds.add(id);
} else {
MessageSender.resend(context, messageRecord);
@ -216,7 +215,7 @@ public final class SafetyNumberChangeRepository {
}
if (Util.hasItems(resendIds)) {
if (messageRecord.getRecipient().isPushGroup()) {
if (messageRecord.getToRecipient().isPushGroup()) {
MessageSender.resendGroupMessage(context, messageRecord, resendIds);
} else {
MessageSender.resendDistributionList(context, messageRecord, resendIds);

View file

@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactUtil
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
/**
* Wraps up the "Add shared contact to contact list" into a contract. The flow here is a little
* weird because buildAddToContactsIntent has to be run in the background (as it loads image data).
*
* Usage:
* Register for result from your fragment, and then pass the created launcher in when you call
* createIntentAndLaunch.
*/
class AddToContactsContract : ActivityResultContract<Intent, Unit>() {
override fun createIntent(context: Context, input: Intent): Intent = input
override fun parseResult(resultCode: Int, intent: Intent?) {
ApplicationDependencies.getJobManager().add(DirectoryRefreshJob(false))
}
companion object {
private val TAG = Log.tag(AddToContactsContract::class.java)
@JvmStatic
fun createIntentAndLaunch(
fragment: Fragment,
launcher: ActivityResultLauncher<Intent>,
contact: Contact
): Disposable {
return Single.fromCallable { ContactUtil.buildAddToContactsIntent(fragment.requireContext(), contact) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
try {
launcher.launch(it)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Could not locate contacts activity", e)
Toast.makeText(fragment.requireContext(), R.string.ConversationFragment__contacts_app_not_found, Toast.LENGTH_SHORT).show()
}
}
}
}
}

View file

@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.conversation.v2
import android.animation.Animator
import android.animation.LayoutTransition
import android.animation.ValueAnimator
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
class BubbleLayoutTransitionListener(
recyclerView: RecyclerView
) : DefaultLifecycleObserver {
private val layoutTransition = LayoutTransition()
private val transitionListener = TransitionListener(recyclerView)
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
layoutTransition.getAnimator(LayoutTransition.CHANGE_DISAPPEARING).addListener(transitionListener)
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
layoutTransition.getAnimator(LayoutTransition.CHANGE_DISAPPEARING).removeListener(transitionListener)
}
private class TransitionListener(recyclerView: RecyclerView) : Animator.AnimatorListener {
private val animator = ValueAnimator.ofFloat(0f, 1f)
init {
animator.addUpdateListener { recyclerView.invalidate() }
animator.duration = 100L
}
override fun onAnimationStart(animation: Animator) {
animator.start()
}
override fun onAnimationEnd(animation: Animator) {
animator.end()
}
override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator) = Unit
}
}

View file

@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Intent
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
/**
* Wrapper activity for ConversationFragment.
*/
class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaControllerOwner {
private val theme = DynamicNoActionBarTheme()
override val voiceNoteMediaController = VoiceNoteMediaController(this, true)
override fun onPreCreate() {
theme.onCreate(this)
}
override fun onResume() {
super.onResume()
theme.onResume(this)
}
override fun getFragment(): Fragment = ConversationFragment().apply {
arguments = intent.extras
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
error("ON NEW INTENT")
}
}

View file

@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.content.DialogInterface
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
/**
* Centralized object for displaying dialogs to the user from the
* conversation fragment.
*/
object ConversationDialogs {
/**
* Dialog which is displayed when the user attempts to start a video call
* as a non-admin in an announcement group.
*/
fun displayCannotStartGroupCallDueToPermissionsDialog(context: Context) {
MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call)
.setPositiveButton(android.R.string.ok) { d: DialogInterface, w: Int -> d.dismiss() }
.show()
}
}

View file

@ -0,0 +1,861 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.ActivityOptions
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactUtil
import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationOptionsMenu
import org.thoughtcrime.securesms.conversation.MarkReadHelper
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.ConversationItemAnimator
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel
import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult
import org.thoughtcrime.securesms.invites.InviteActions
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory.create
import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity
import org.thoughtcrime.securesms.mms.AttachmentManager
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity
import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DrawableUtil
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
import java.util.Locale
/**
* A single unified fragment for Conversations.
*/
class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) {
companion object {
private val TAG = Log.tag(ConversationFragment::class.java)
}
private val args: ConversationIntents.Args by lazy {
ConversationIntents.Args.from(requireArguments())
}
private val disposables = LifecycleDisposable()
private val binding by ViewBinderDelegate(V2ConversationFragmentBinding::bind)
private val viewModel: ConversationViewModel by viewModels(
factoryProducer = {
ConversationViewModel.Factory(args, ConversationRepository(requireContext()))
}
)
private val groupCallViewModel: ConversationGroupCallViewModel by viewModels(
factoryProducer = {
ConversationGroupCallViewModel.Factory(args.threadId)
}
)
private val conversationGroupViewModel: ConversationGroupViewModel by viewModels(
factoryProducer = {
ConversationGroupViewModel.Factory(args.threadId)
}
)
private val conversationTooltips = ConversationTooltips(this)
private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider
private lateinit var layoutManager: SmoothScrollingLinearLayoutManager
private lateinit var markReadHelper: MarkReadHelper
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
private lateinit var adapter: ConversationAdapter
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
ScrollToPositionDelegate.JumpToPositionStrategy.performScroll(recyclerView, layoutManager, position, smooth)
adapter.pulseAtPosition(position)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
registerForResults()
conversationOptionsMenuProvider = ConversationOptionsMenu.Provider(ConversationOptionsMenuCallback(), disposables)
markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner)
FullscreenHelper(requireActivity()).showSystemUI()
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
binding.conversationItemRecycler.setHasFixedSize(false)
binding.conversationItemRecycler.layoutManager = layoutManager
val layoutTransitionListener = BubbleLayoutTransitionListener(binding.conversationItemRecycler)
viewLifecycleOwner.lifecycle.addObserver(layoutTransitionListener)
val recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler)
recyclerViewColorizer.setChatColors(args.chatColors)
val conversationToolbarOnScrollHelper = ConversationToolbarOnScrollHelper(
requireActivity(),
binding.toolbar,
viewModel::wallpaperSnapshot
)
conversationToolbarOnScrollHelper.attach(binding.conversationItemRecycler)
disposables.bindTo(viewLifecycleOwner)
disposables += viewModel.recipient
.firstOrError()
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onSuccess = {
onFirstRecipientLoad(it)
})
presentWallpaper(args.wallpaper)
disposables += viewModel.recipient
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onNext = {
recyclerViewColorizer.setChatColors(it.chatColors)
presentWallpaper(it.wallpaper)
presentConversationTitle(it)
})
disposables += viewModel.markReadRequests
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onNext = markReadHelper::onViewsRevealed)
EventBus.getDefault().registerForLifecycle(groupCallViewModel, viewLifecycleOwner)
presentGroupCallJoinButton()
}
override fun onResume() {
super.onResume()
WindowUtil.setLightNavigationBarFromTheme(requireActivity())
WindowUtil.setLightStatusBarFromTheme(requireActivity())
groupCallViewModel.peekGroupCall()
if (!args.conversationScreenType.isInBubble) {
ApplicationDependencies.getMessageNotifier().setVisibleThread(ConversationId.forConversation(args.threadId))
}
}
override fun onPause() {
super.onPause()
ApplicationDependencies.getMessageNotifier().clearVisibleThread()
}
private fun registerForResults() {
addToContactsLauncher = registerForActivityResult(AddToContactsContract()) {}
}
private fun onFirstRecipientLoad(recipient: Recipient) {
Log.d(TAG, "onFirstRecipientLoad")
val colorizer = Colorizer()
adapter = ConversationAdapter(
requireContext(),
viewLifecycleOwner,
GlideApp.with(this),
Locale.getDefault(),
ConversationItemClickListener(),
recipient,
colorizer
)
scrollToPositionDelegate = ScrollToPositionDelegate(
binding.conversationItemRecycler,
adapter::canJumpToPosition,
adapter::getAdapterPositionForMessagePosition
)
binding.conversationItemRecycler.itemAnimator = ConversationItemAnimator(
isInMultiSelectMode = adapter.selectedItems::isNotEmpty,
shouldPlayMessageAnimations = {
scrollToPositionDelegate.isListCommitted() && binding.conversationItemRecycler.scrollState == RecyclerView.SCROLL_STATE_IDLE
},
isParentFilled = {
binding.conversationItemRecycler.canScrollVertically(1) || binding.conversationItemRecycler.canScrollVertically(-1)
}
)
ConversationAdapter.initializePool(binding.conversationItemRecycler.recycledViewPool)
adapter.setPagingController(viewModel.pagingController)
adapter.registerAdapterDataObserver(DataObserver(scrollToPositionDelegate))
viewLifecycleOwner.lifecycle.addObserver(LastSeenPositionUpdater(adapter, layoutManager, viewModel))
binding.conversationItemRecycler.adapter = adapter
giphyMp4ProjectionRecycler = initializeGiphyMp4()
val multiselectItemDecoration = MultiselectItemDecoration(
requireContext()
) { viewModel.wallpaperSnapshot }
binding.conversationItemRecycler.addItemDecoration(multiselectItemDecoration)
viewLifecycleOwner.lifecycle.addObserver(multiselectItemDecoration)
disposables += viewModel
.conversationThreadState
.doOnSuccess {
scrollToPositionDelegate.requestScrollPosition(
position = it.meta.getStartPosition(),
smooth = false,
awaitLayout = false
)
}
.flatMapObservable { it.items.data }
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onNext = {
adapter.submitList(it) {
scrollToPositionDelegate.notifyListCommitted()
}
})
disposables += viewModel
.nameColorsMap
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onNext = {
colorizer.onNameColorsChanged(it)
adapter.notifyItemRangeChanged(0, adapter.itemCount)
})
binding.conversationItemRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val timestamp = MarkReadHelper.getLatestTimestamp(adapter, layoutManager)
timestamp.ifPresent(viewModel::requestMarkRead)
}
})
presentActionBarMenu()
}
private fun invalidateOptionsMenu() {
// TODO [alex] -- Handle search... is there a better way to manage this state? Maybe an event system?
conversationOptionsMenuProvider.onCreateMenu(binding.toolbar.menu, requireActivity().menuInflater)
}
private fun presentActionBarMenu() {
invalidateOptionsMenu()
when (args.conversationScreenType) {
ConversationScreenType.NORMAL -> presentNavigationIconForNormal()
ConversationScreenType.BUBBLE -> presentNavigationIconForBubble()
ConversationScreenType.POPUP -> Unit
}
binding.toolbar.setOnMenuItemClickListener(conversationOptionsMenuProvider::onMenuItemSelected)
}
private fun presentNavigationIconForNormal() {
binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_left_24)
binding.toolbar.setNavigationOnClickListener {
requireActivity().finishAfterTransition()
}
}
private fun presentNavigationIconForBubble() {
binding.toolbar.navigationIcon = DrawableUtil.tint(
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_notification),
ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)
)
binding.toolbar.setNavigationOnClickListener {
startActivity(MainActivity.clearTop(requireContext()))
}
}
private fun presentConversationTitle(recipient: Recipient) {
binding.conversationTitleView.root.setTitle(GlideApp.with(this), recipient)
}
private fun presentWallpaper(chatWallpaper: ChatWallpaper?) {
if (chatWallpaper != null) {
chatWallpaper.loadInto(binding.conversationWallpaper)
ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper)
} else {
binding.conversationWallpaperDim.visible = false
}
binding.conversationWallpaper.visible = chatWallpaper != null
}
private fun presentGroupCallJoinButton() {
binding.conversationGroupCallJoin.setOnClickListener {
handleVideoCall()
}
disposables += groupCallViewModel.hasActiveGroupCall.subscribeBy(onNext = {
invalidateOptionsMenu()
binding.conversationGroupCallJoin.visible = it
})
disposables += groupCallViewModel.hasCapacity.subscribeBy(onNext = {
binding.conversationGroupCallJoin.setText(
if (it) R.string.ConversationActivity_join else R.string.ConversationActivity_full
)
})
}
private fun handleVideoCall() {
val recipient: Single<Recipient> = viewModel.recipient.firstOrError()
val hasActiveGroupCall: Single<Boolean> = groupCallViewModel.hasActiveGroupCall.firstOrError()
val isNonAdminInAnnouncementGroup: Boolean = conversationGroupViewModel.isNonAdminInAnnouncementGroup()
val cannotCreateGroupCall = Single.zip(recipient, hasActiveGroupCall) { r, active ->
r to (r.isPushV2Group && !active && isNonAdminInAnnouncementGroup)
}
disposables += cannotCreateGroupCall
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (recipient, notAllowed) ->
if (notAllowed) {
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
} else {
CommunicationActions.startVideoCall(this, recipient)
}
}
}
private fun handleBlockJoinRequest(recipient: Recipient) {
disposables += conversationGroupViewModel.blockJoinRequests(recipient).subscribeBy { result ->
if (result.isFailure()) {
val failureReason = GroupErrors.getUserDisplayMessage((result as GroupBlockJoinRequestResult.Failure).reason)
Toast.makeText(requireContext(), failureReason, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(requireContext(), R.string.ConversationFragment__blocked, Toast.LENGTH_SHORT).show()
}
}
}
private fun getVoiceNoteMediaController() = requireListener<VoiceNoteMediaControllerOwner>().voiceNoteMediaController
private fun initializeGiphyMp4(): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
binding.conversationVideoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(binding.conversationItemRecycler, callback, maxPlayback)
binding.conversationItemRecycler.addItemDecoration(
GiphyMp4ItemDecoration(callback) { translationY: Float ->
// TODO [alex] reactionsShade.setTranslationY(translationY + list.getHeight())
},
0
)
return callback
}
private fun toast(@StringRes toastTextId: Int, toastDuration: Int) {
ThreadUtil.runOnMain {
if (context != null) {
Toast.makeText(context, toastTextId, toastDuration).show()
} else {
Log.w(TAG, "Dropping toast without context.")
}
}
}
/**
* Requests a jump to the desired position, and ensures that the position desired will be visible on the screen.
*/
private fun moveToPosition(position: Int) {
scrollToPositionDelegate.requestScrollPosition(
position = position,
smooth = true,
scrollStrategy = jumpAndPulseScrollStrategy
)
}
private inner class DataObserver(
private val scrollToPositionDelegate: ScrollToPositionDelegate
) : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
Log.d(TAG, "onItemRangeInserted $positionStart $itemCount")
if (positionStart == 0 && itemCount == 1 && !binding.conversationItemRecycler.canScrollVertically(1)) {
Log.d(TAG, "Requesting scroll to bottom.")
scrollToPositionDelegate.resetScrollPosition()
}
}
}
private inner class ConversationItemClickListener : ConversationAdapter.ItemClickListener {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) {
val quote: Quote? = messageRecord.quote
if (quote == null) {
Log.w(TAG, "onQuoteClicked: Received an event but there is no quote.")
return
}
if (quote.isOriginalMissing) {
Log.i(TAG, "onQuoteClicked: Original message is missing.")
toast(R.string.ConversationFragment_quoted_message_not_found, Toast.LENGTH_SHORT)
return
}
val parentStoryId = messageRecord.parentStoryId
if (parentStoryId != null) {
startActivity(
StoryViewerActivity.createIntent(
requireContext(),
StoryViewerArgs.Builder(quote.author, Recipient.resolved(quote.author).shouldHideStory())
.withStoryId(parentStoryId.asMessageId().id)
.isFromQuote(true)
.build()
)
)
return
}
disposables += viewModel.getQuotedMessagePosition(quote)
.subscribeBy {
if (it >= 0) {
moveToPosition(it)
} else {
toast(R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT)
}
}
}
override fun onLinkPreviewClicked(linkPreview: LinkPreview) {
val activity = activity ?: return
CommunicationActions.openBrowserLink(activity, linkPreview.url)
}
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) {
// TODO [alex] - ("Not yet implemented")
}
override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) {
context ?: return
LongMessageFragment.create(messageId, isMms).show(childFragmentManager, null)
}
override fun onStickerClicked(stickerLocator: StickerLocator) {
context ?: return
startActivity(StickerPackPreviewActivity.getIntent(stickerLocator.packId, stickerLocator.packKey))
}
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) {
// TODO [alex] - ("Not yet implemented")
}
override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) {
val activity = activity ?: return
ViewCompat.setTransitionName(avatarTransitionView, "avatar")
val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, avatarTransitionView, "avatar").toBundle()
ActivityCompat.startActivity(activity, SharedContactDetailsActivity.getIntent(activity, contact), bundle)
}
override fun onAddToContactsClicked(contact: Contact) {
disposables += AddToContactsContract.createIntentAndLaunch(
this@ConversationFragment,
addToContactsLauncher,
contact
)
}
override fun onMessageSharedContactClicked(choices: MutableList<Recipient>) {
val context = context ?: return
ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient ->
CommunicationActions.startConversation(context, recipient, null)
}
}
override fun onInviteSharedContactClicked(choices: MutableList<Recipient>) {
val context = context ?: return
ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient ->
CommunicationActions.composeSmsThroughDefaultApp(
context,
recipient,
getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))
)
}
}
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) {
context ?: return
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(parentFragmentManager, null)
}
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) {
context ?: return
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(parentFragmentManager, "BOTTOM")
}
override fun onMessageWithErrorClicked(messageRecord: MessageRecord) {
// TODO [alex] - ("Not yet implemented")
}
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) {
RecaptchaProofBottomSheetFragment.show(childFragmentManager)
}
override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) {
SafetyNumberBottomSheet.forRecipientId(recipientId).show(parentFragmentManager)
}
override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState>) {
getVoiceNoteMediaController()
.voiceNotePlaybackState
.observe(viewLifecycleOwner, onPlaybackStartObserver)
}
override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState>) {
getVoiceNoteMediaController()
.voiceNotePlaybackState
.removeObserver(onPlaybackStartObserver)
}
override fun onVoiceNotePause(uri: Uri) {
getVoiceNoteMediaController().pausePlayback(uri)
}
override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) {
getVoiceNoteMediaController().startConsecutivePlayback(uri, messageId, position)
}
override fun onVoiceNoteSeekTo(uri: Uri, position: Double) {
getVoiceNoteMediaController().seekToPosition(uri, position)
}
override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) {
getVoiceNoteMediaController().setPlaybackSpeed(uri, speed)
}
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onChatSessionRefreshLearnMoreClicked() {
// TODO [alex] -- ("Not yet implemented")
}
override fun onBadDecryptLearnMoreClicked(author: RecipientId) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onJoinGroupCallClicked() {
// TODO [alex] -- ("Not yet implemented")
}
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onEnableCallNotificationsClicked() {
// TODO [alex] -- ("Not yet implemented")
}
override fun onPlayInlineContent(conversationMessage: ConversationMessage?) {
// TODO [alex] - ("Not yet implemented")
}
override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) {
// TODO [alex] - ("Not yet implemented")
}
override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) {
// TODO [alex] - ("Not yet implemented")
}
override fun onChangeNumberUpdateContact(recipient: Recipient) {
// TODO [alex] - ("Not yet implemented")
}
override fun onCallToAction(action: String) {
// TODO [alex] - ("Not yet implemented")
}
override fun onDonateClicked() {
// TODO [alex] - ("Not yet implemented")
}
override fun onBlockJoinRequest(recipient: Recipient) {
MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.ConversationFragment__block_request)
.setMessage(getString(R.string.ConversationFragment__s_will_not_be_able_to_join_or_request_to_join_this_group_via_the_group_link, recipient.getDisplayName(requireContext())))
.setNegativeButton(R.string.ConversationFragment__cancel, null)
.setPositiveButton(R.string.ConversationFragment__block_request_button) { _, _ -> handleBlockJoinRequest(recipient) }
.show()
}
override fun onRecipientNameClicked(target: RecipientId) {
context ?: return
disposables += viewModel.recipient.firstOrError().observeOn(AndroidSchedulers.mainThread()).subscribeBy {
RecipientBottomSheetDialogFragment.create(
target,
it.groupId.orElse(null)
).show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
override fun onInviteToSignalClicked() {
val recipient = viewModel.recipientSnapshot ?: return
InviteActions.inviteUserToSignal(
requireContext(),
recipient,
{}, // TODO [alex] -- append to compose
this@ConversationFragment::startActivity
)
}
override fun onActivatePaymentsClicked() {
startActivity(Intent(requireContext(), PaymentsActivity::class.java))
}
override fun onSendPaymentClicked(recipientId: RecipientId) {
disposables += viewModel.recipient
.firstOrError()
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
AttachmentManager.selectPayment(this@ConversationFragment, it)
}
}
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onUrlClicked(url: String): Boolean {
return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url)
}
override fun goToMediaPreview(parent: ConversationItem, sharedElement: View, args: MediaIntentFactory.MediaPreviewArgs) {
if (this@ConversationFragment.args.conversationScreenType.isInBubble) {
requireActivity().startActivity(create(requireActivity(), args.skipSharedElementTransition(true)))
return
}
if (args.isVideoGif) {
val adapterPosition: Int = binding.conversationItemRecycler.getChildAdapterPosition(parent)
val holder: GiphyMp4ProjectionPlayerHolder? = giphyMp4ProjectionRecycler.getCurrentHolder(adapterPosition)
if (holder != null) {
parent.showProjectionArea()
holder.hide()
}
}
sharedElement.transitionName = MediaPreviewV2Activity.SHARED_ELEMENT_TRANSITION_NAME
requireActivity().setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
val options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), sharedElement, MediaPreviewV2Activity.SHARED_ELEMENT_TRANSITION_NAME)
requireActivity().startActivity(create(requireActivity(), args), options.toBundle())
}
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onItemClick(item: MultiselectPart?) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onItemLongClick(itemView: View?, item: MultiselectPart?) {
// TODO [alex] -- ("Not yet implemented")
}
}
private inner class ConversationOptionsMenuCallback : ConversationOptionsMenu.Callback {
override fun getSnapshot(): ConversationOptionsMenu.Snapshot {
val recipient: Recipient? = viewModel.recipientSnapshot
return ConversationOptionsMenu.Snapshot(
recipient = recipient,
isPushAvailable = true, // TODO [alex]
canShowAsBubble = Observable.empty(),
isActiveGroup = recipient?.isActiveGroup == true,
isActiveV2Group = recipient?.let { it.isActiveGroup && it.isPushV2Group } == true,
isInActiveGroup = recipient?.isActiveGroup == false,
hasActiveGroupCall = groupCallViewModel.hasActiveGroupCallSnapshot,
distributionType = args.distributionType,
threadId = args.threadId,
isInMessageRequest = false, // TODO [alex]
isInBubble = args.conversationScreenType.isInBubble
)
}
override fun onOptionsMenuCreated(menu: Menu) {
// TODO [alex]
}
override fun handleVideo() {
this@ConversationFragment.handleVideoCall()
}
override fun handleDial(isSecure: Boolean) {
// TODO [alex] - ("Not yet implemented")
}
override fun handleViewMedia() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleAddShortcut() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleSearch() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleAddToContacts() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleDisplayGroupRecipients() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleDistributionBroadcastEnabled(menuItem: MenuItem) {
// TODO [alex] - ("Not yet implemented")
}
override fun handleDistributionConversationEnabled(menuItem: MenuItem) {
// TODO [alex] - ("Not yet implemented")
}
override fun handleManageGroup() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleLeavePushGroup() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleInviteLink() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleMuteNotifications() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleUnmuteNotifications() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleConversationSettings() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleSelectMessageExpiration() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleCreateBubble() {
// TODO [alex] - ("Not yet implemented")
}
override fun handleGoHome() {
// TODO [alex] - ("Not yet implemented")
}
override fun showExpiring(recipient: Recipient) {
binding.conversationTitleView.root.showExpiring(recipient)
}
override fun clearExpiring() {
binding.conversationTitleView.root.clearExpiring()
}
override fun showGroupCallingTooltip() {
conversationTooltips.displayGroupCallingTooltip(requireView().findViewById(R.id.menu_video_secure))
}
}
private class LastSeenPositionUpdater(
val adapter: ConversationAdapter,
val layoutManager: SmoothScrollingLinearLayoutManager,
val viewModel: ConversationViewModel
) : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val firstVisiblePosition = layoutManager.findFirstCompletelyVisibleItemPosition()
val lastVisibleMessageTimestamp = if (firstVisiblePosition > 0 && lastVisiblePosition != RecyclerView.NO_POSITION) {
adapter.getLastVisibleConversationMessage(lastVisiblePosition)?.messageRecord?.dateReceived ?: 0L
} else {
0L
}
viewModel.setLastScrolled(lastVisibleMessageTimestamp)
}
}
}

View file

@ -0,0 +1,107 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.thoughtcrime.securesms.conversation.ConversationDataSource
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import kotlin.math.max
class ConversationRepository(context: Context) {
private val applicationContext = context.applicationContext
private val oldConversationRepository = org.thoughtcrime.securesms.conversation.ConversationRepository()
/**
* Observes the recipient tied to the given thread id, returning an error if
* the thread id does not exist or somehow does not have a recipient attached to it.
*/
fun observeRecipientForThread(threadId: Long): Observable<Recipient> {
return Observable.create { emitter ->
val recipientId = SignalDatabase.threads.getRecipientIdForThreadId(threadId)
if (recipientId != null) {
val disposable = Recipient.live(recipientId).observable()
.subscribeOn(Schedulers.io())
.subscribeBy(onNext = emitter::onNext)
emitter.setCancellable {
disposable.dispose()
}
} else {
emitter.onError(Exception("Thread $threadId does not exist."))
}
}.subscribeOn(Schedulers.io())
}
/**
* Loads the details necessary to display the conversation thread.
*/
fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single<ConversationThreadState> {
return Single.fromCallable {
val recipient = threads.getRecipientForThreadId(threadId)!!
val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition)
val messageRequestData = metadata.messageRequestData
val dataSource = ConversationDataSource(
applicationContext,
threadId,
messageRequestData,
metadata.showUniversalExpireTimerMessage,
metadata.threadSize
)
val config = PagingConfig.Builder().setPageSize(25)
.setBufferPages(2)
.setStartIndex(max(metadata.getStartPosition(), 0))
.build()
ConversationThreadState(
items = PagedData.createForObservable(dataSource, config),
meta = metadata
)
}
}
/**
* Generates the name color-map for groups.
*/
fun getNameColorsMap(
recipient: Recipient,
groupAuthorNameColorHelper: GroupAuthorNameColorHelper
): Observable<Map<RecipientId, NameColor>> {
return Recipient.observable(recipient.id)
.distinctUntilChanged { a, b -> a.participantIds == b.participantIds }
.map {
if (it.groupId.isPresent) {
groupAuthorNameColorHelper.getColorMap(it.requireGroupId())
} else {
emptyMap()
}
}
.subscribeOn(Schedulers.io())
}
fun setLastVisibleMessageTimestamp(threadId: Long, lastVisibleMessageTimestamp: Long) {
SignalExecutors.BOUNDED.submit { threads.setLastScrolled(threadId, lastVisibleMessageTimestamp) }
}
fun markGiftBadgeRevealed(messageId: Long) {
oldConversationRepository.markGiftBadgeRevealed(messageId)
}
fun getQuotedMessagePosition(threadId: Long, quote: Quote): Single<Int> {
return Single.fromCallable {
SignalDatabase.messages.getQuotedMessagePosition(threadId, quote.id, quote.author)
}.subscribeOn(Schedulers.io())
}
}

View file

@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.conversation.v2
import org.signal.paging.ObservablePagedData
import org.thoughtcrime.securesms.conversation.ConversationData
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.model.MessageId
/**
* Represents the content that will be displayed in the conversation
* thread (recycler).
*/
class ConversationThreadState(
val items: ObservablePagedData<MessageId, ConversationMessage>,
val meta: ConversationData
)

View file

@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.view.View
import androidx.annotation.ColorRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
/**
* Scroll helper to manage the color state of the top bar and status bar.
*/
class ConversationToolbarOnScrollHelper(
activity: Activity,
toolbarBackground: View,
private val wallpaperProvider: () -> ChatWallpaper?
) : Material3OnScrollHelper(
activity,
listOf(toolbarBackground),
emptyList()
) {
override val activeColorSet: ColorSet
get() = ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
override val inactiveColorSet: ColorSet
get() = ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
@ColorRes
private fun getActiveToolbarColor(hasWallpaper: Boolean): Int {
return if (hasWallpaper) R.color.conversation_toolbar_color_wallpaper_scrolled else R.color.signal_colorSurface2
}
@ColorRes
private fun getInactiveToolbarColor(hasWallpaper: Boolean): Int {
return if (hasWallpaper) R.color.conversation_toolbar_color_wallpaper else R.color.signal_colorBackground
}
}

View file

@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.conversation.v2
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Any and all tooltips that the conversation can display, and a light amount of related presentation logic.
*/
class ConversationTooltips(fragment: Fragment) {
companion object {
private val TAG = Log.tag(ConversationTooltips::class.java)
}
private val viewModel: TooltipViewModel by fragment.viewModels()
/**
* Displays the tooltip notifying the user that they can begin a group call. Also
* performs the necessary record-keeping and checks to ensure we don't display it
* if we shouldn't. There is a set of callbacks which should be used to preserve
* session state for this tooltip.
*
* @param anchor The view this will be displayed underneath. If the view is not ready, we will skip.
*/
fun displayGroupCallingTooltip(
anchor: View?
) {
if (viewModel.hasDisplayedCallingTooltip || !SignalStore.tooltips().shouldShowGroupCallingTooltip()) {
return
}
if (anchor == null) {
Log.w(TAG, "Group calling tooltip anchor is null. Skipping tooltip.")
return
}
viewModel.hasDisplayedCallingTooltip = true
SignalStore.tooltips().markGroupCallSpeakerViewSeen()
TooltipPopup.forTarget(anchor)
.setBackgroundTint(ContextCompat.getColor(anchor.context, R.color.signal_accent_green))
.setTextColor(ContextCompat.getColor(anchor.context, R.color.core_white))
.setText(R.string.ConversationActivity__tap_here_to_start_a_group_call)
.setOnDismissListener { SignalStore.tooltips().markGroupCallingTooltipSeen() }
.show(TooltipPopup.POSITION_BELOW)
}
/**
* ViewModel which holds different bits of session-local persistent state for different tooltips.
*/
class TooltipViewModel : ViewModel() {
var hasDisplayedCallingTooltip: Boolean = false
}
}

View file

@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.conversation.ConversationIntents.Args
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
/**
* ConversationViewModel, which operates solely off of a thread id that never changes.
*/
class ConversationViewModel(
private val threadId: Long,
requestedStartingPosition: Int,
private val repository: ConversationRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
private val _recipient: BehaviorSubject<Recipient> = BehaviorSubject.create()
val recipient: Observable<Recipient> = _recipient
private val _conversationThreadState: Subject<ConversationThreadState> = BehaviorSubject.create()
val conversationThreadState: Single<ConversationThreadState> = _conversationThreadState.firstOrError()
private val _markReadProcessor: PublishProcessor<Long> = PublishProcessor.create()
val markReadRequests: Flowable<Long> = _markReadProcessor
.onBackpressureBuffer()
.distinct()
val pagingController = ProxyPagingController<MessageId>()
val nameColorsMap: Observable<Map<RecipientId, NameColor>> = _recipient.flatMap { repository.getNameColorsMap(it, groupAuthorNameColorHelper) }
val recipientSnapshot: Recipient?
get() = _recipient.value
val wallpaperSnapshot: ChatWallpaper?
get() = _recipient.value?.wallpaper
init {
disposables += repository.observeRecipientForThread(threadId)
.subscribeBy(onNext = _recipient::onNext)
disposables += repository.getConversationThreadState(threadId, requestedStartingPosition)
.subscribeBy(onSuccess = {
pagingController.set(it.items.controller)
_conversationThreadState.onNext(it)
})
disposables += _conversationThreadState.firstOrError().flatMapObservable { threadState ->
Observable.create<Unit> { emitter ->
val controller = threadState.items.controller
val messageUpdateObserver = DatabaseObserver.MessageObserver {
controller.onDataItemChanged(it)
}
val messageInsertObserver = DatabaseObserver.MessageObserver {
controller.onDataItemInserted(it, 0)
}
val conversationObserver = DatabaseObserver.Observer {
controller.onDataInvalidated()
}
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageUpdateObserver)
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, messageInsertObserver)
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, conversationObserver)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageUpdateObserver)
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageInsertObserver)
ApplicationDependencies.getDatabaseObserver().unregisterObserver(conversationObserver)
}
}
}.subscribe()
}
override fun onCleared() {
disposables.clear()
}
fun getQuotedMessagePosition(quote: Quote): Single<Int> {
return repository.getQuotedMessagePosition(threadId, quote)
}
fun setLastScrolled(lastScrolledTimestamp: Long) {
repository.setLastVisibleMessageTimestamp(
threadId,
lastScrolledTimestamp
)
}
fun markGiftBadgeRevealed(messageRecord: MessageRecord) {
if (messageRecord.isOutgoing && messageRecord.hasGiftBadge()) {
repository.markGiftBadgeRevealed(messageRecord.id)
}
}
fun requestMarkRead(timestamp: Long) {
}
class Factory(
private val args: Args,
private val repository: ConversationRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationViewModel(args.threadId, args.startingPosition, repository)) as T
}
}
}

View file

@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.conversation.v2
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log
/**
* Set up a lifecycle aware register/deregister for the lifecycleowner.
*/
fun EventBus.registerForLifecycle(subscriber: Any, lifecycleOwner: LifecycleOwner) {
val registration = LifecycleAwareRegistration(subscriber, this)
lifecycleOwner.lifecycle.addObserver(registration)
}
private class LifecycleAwareRegistration(
private val subscriber: Any,
private val bus: EventBus
) : DefaultLifecycleObserver {
companion object {
private val TAG = Log.tag(LifecycleAwareRegistration::class.java)
}
override fun onResume(owner: LifecycleOwner) {
Log.d(TAG, "Registering owner.")
bus.register(subscriber)
}
override fun onPause(owner: LifecycleOwner) {
Log.d(TAG, "Unregistering owner.")
bus.unregister(subscriber)
}
}

View file

@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.conversation.v2.groups
/**
* Represents the 'active' state of a group.
*/
data class ConversationGroupActiveState(
val isActive: Boolean,
private val isV2: Boolean
) {
val isActiveV2: Boolean = isActive && isV2
}

View file

@ -0,0 +1,110 @@
package org.thoughtcrime.securesms.conversation.v2.groups
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.events.GroupCallPeekEvent
import org.thoughtcrime.securesms.recipients.Recipient
/**
* ViewModel which manages state associated with group calls.
*/
class ConversationGroupCallViewModel(threadId: Long) : ViewModel() {
companion object {
private val TAG = Log.tag(ConversationGroupCallViewModel::class.java)
}
private val _isGroupActive: Subject<Boolean> = BehaviorSubject.createDefault(false)
private val _hasOngoingGroupCall: Subject<Boolean> = BehaviorSubject.createDefault(false)
private val _hasCapacity: Subject<Boolean> = BehaviorSubject.createDefault(false)
private val _hasActiveGroupCall: BehaviorSubject<Boolean> = BehaviorSubject.create()
private val _recipient: BehaviorSubject<Recipient> = BehaviorSubject.create()
private val _groupCallPeekEventProcessor: PublishProcessor<GroupCallPeekEvent> = PublishProcessor.create()
private val _peekRequestProcessor: PublishProcessor<Unit> = PublishProcessor.create()
private val disposables = CompositeDisposable()
val hasActiveGroupCall: Observable<Boolean> = _hasActiveGroupCall.observeOn(AndroidSchedulers.mainThread())
val hasCapacity: Observable<Boolean> = _hasCapacity.observeOn(AndroidSchedulers.mainThread())
val hasActiveGroupCallSnapshot: Boolean
get() = _hasActiveGroupCall.value == true
init {
disposables += Observable
.combineLatest(_isGroupActive, _hasActiveGroupCall) { a, b -> a && b }
.subscribeBy(onNext = _hasActiveGroupCall::onNext)
disposables += Single
.fromCallable { SignalDatabase.threads.getRecipientForThreadId(threadId)!! }
.subscribeOn(Schedulers.io())
.filter { it.isPushV2Group }
.flatMapObservable { Recipient.live(it.id).observable() }
.subscribeBy(onNext = _recipient::onNext)
disposables += _recipient
.map { it.isActiveGroup }
.distinctUntilChanged()
.subscribeBy(onNext = _isGroupActive::onNext)
disposables += _recipient
.firstOrError()
.subscribeBy(onSuccess = {
peekGroupCall()
})
disposables += _groupCallPeekEventProcessor
.onBackpressureLatest()
.switchMap { event ->
_recipient.firstElement().map { it.id }.filter { it == event.groupRecipientId }.map { event }.toFlowable()
}
.subscribeBy(onNext = {
Log.i(TAG, "update UI with call event: ongoing call: " + it.isOngoing + " hasCapacity: " + it.callHasCapacity())
_hasOngoingGroupCall.onNext(it.isOngoing)
_hasCapacity.onNext(it.callHasCapacity())
})
disposables += _peekRequestProcessor
.onBackpressureLatest()
.switchMap {
_recipient.firstOrError().map { it.id }.toFlowable()
}
.subscribeBy(onNext = { recipientId ->
Log.i(TAG, "peek call for $recipientId")
ApplicationDependencies.getSignalCallManager().peekGroupCall(recipientId)
})
}
override fun onCleared() {
disposables.clear()
}
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onGroupCallPeekEvent(groupCallPeekEvent: GroupCallPeekEvent) {
_groupCallPeekEventProcessor.onNext(groupCallPeekEvent)
}
fun peekGroupCall() {
_peekRequestProcessor.onNext(Unit)
}
class Factory(private val threadId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationGroupCallViewModel(threadId)) as T
}
}
}

View file

@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.conversation.v2.groups
import org.thoughtcrime.securesms.database.GroupTable
/**
* @param groupTableMemberLevel Self membership level
* @param isAnnouncementGroup Whether the group is an announcement group.
*/
data class ConversationGroupMemberLevel(
val groupTableMemberLevel: GroupTable.MemberLevel,
val isAnnouncementGroup: Boolean
)

View file

@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.conversation.v2.groups
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents detected duplicate recipients that should be displayed
* to the user as a warning.
*
* @param groupId The groupId for the conversation
* @param recipient The first recipient in the list of duplicates
* @param count The number of duplicates
*/
data class ConversationGroupReviewState(
val groupId: GroupId.V2?,
val recipient: Recipient,
val count: Int
) {
companion object {
val EMPTY = ConversationGroupReviewState(null, Recipient.UNKNOWN, 0)
}
}

View file

@ -0,0 +1,123 @@
package org.thoughtcrime.securesms.conversation.v2.groups
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil
import org.thoughtcrime.securesms.groups.v2.GroupBlockJoinRequestResult
import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Manages group state and actions for conversations.
*/
class ConversationGroupViewModel(
private val threadId: Long,
private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private val _recipient: Subject<Recipient> = BehaviorSubject.create()
private val _groupRecord: Subject<GroupRecord> = BehaviorSubject.create()
private val _groupActiveState: Subject<ConversationGroupActiveState> = BehaviorSubject.create()
private val _memberLevel: BehaviorSubject<ConversationGroupMemberLevel> = BehaviorSubject.create()
private val _actionableRequestingMembersCount: Subject<Int> = BehaviorSubject.create()
private val _gv1MigrationSuggestions: Subject<List<RecipientId>> = BehaviorSubject.create()
private val _reviewState: Subject<ConversationGroupReviewState> = BehaviorSubject.create()
init {
disposables += Single
.fromCallable { SignalDatabase.threads.getRecipientForThreadId(threadId)!! }
.subscribeOn(Schedulers.io())
.filter { it.isGroup }
.flatMapObservable { Recipient.observable(it.id) }
.subscribeBy(onNext = _recipient::onNext)
disposables += _recipient
.switchMap {
Observable.fromCallable {
SignalDatabase.groups.getGroup(it.id).get()
}
}
.subscribeBy(onNext = _groupRecord::onNext)
val duplicates = _groupRecord.map {
if (it.isV2Group) {
ReviewUtil.getDuplicatedRecipients(it.id.requireV2()).map { it.recipient }
} else {
emptyList()
}
}
disposables += Observable.combineLatest(_groupRecord, duplicates) { record, dupes ->
if (dupes.isEmpty()) {
ConversationGroupReviewState.EMPTY
} else {
ConversationGroupReviewState(record.id.requireV2(), dupes[0], dupes.size)
}
}.subscribeBy(onNext = _reviewState::onNext)
disposables += _groupRecord.subscribe { groupRecord ->
_groupActiveState.onNext(ConversationGroupActiveState(groupRecord.isActive, groupRecord.isV2Group))
_memberLevel.onNext(ConversationGroupMemberLevel(groupRecord.memberLevel(Recipient.self()), groupRecord.isAnnouncementGroup))
_actionableRequestingMembersCount.onNext(getActionableRequestingMembersCount(groupRecord))
_gv1MigrationSuggestions.onNext(getGv1MigrationSuggestions(groupRecord))
}
}
override fun onCleared() {
disposables.clear()
}
fun isNonAdminInAnnouncementGroup(): Boolean {
val memberLevel = _memberLevel.value ?: return false
return memberLevel.groupTableMemberLevel != GroupTable.MemberLevel.ADMINISTRATOR && memberLevel.isAnnouncementGroup
}
fun blockJoinRequests(recipient: Recipient): Single<GroupBlockJoinRequestResult> {
return _recipient.firstOrError().flatMap {
groupManagementRepository.blockJoinRequests(it.requireGroupId().requireV2(), recipient)
}.observeOn(AndroidSchedulers.mainThread())
}
private fun getActionableRequestingMembersCount(groupRecord: GroupRecord): Int {
return if (groupRecord.isV2Group && groupRecord.memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR) {
groupRecord.requireV2GroupProperties()
.decryptedGroup
.requestingMembersCount
} else {
0
}
}
private fun getGv1MigrationSuggestions(groupRecord: GroupRecord): List<RecipientId> {
return if (!groupRecord.isActive || !groupRecord.isV2Group || groupRecord.isPendingMember(Recipient.self())) {
emptyList()
} else {
groupRecord.unmigratedV1Members
.filterNot { groupRecord.members.contains(it) }
.map { Recipient.resolved(it) }
.filter { GroupsV1MigrationUtil.isAutoMigratable(it) }
.map { it.id }
}
}
class Factory(private val threadId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationGroupViewModel(threadId)) as T
}
}
}

View file

@ -5,7 +5,6 @@ import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.appbar.AppBarLayout
import org.thoughtcrime.securesms.util.FeatureFlags

View file

@ -79,6 +79,7 @@ import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
@ -90,7 +91,6 @@ import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.Material3SearchToolbar;
import org.thoughtcrime.securesms.components.SignalProgressDialog;
import org.thoughtcrime.securesms.components.UnreadPaymentsView;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
@ -125,7 +125,6 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFi
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
@ -146,7 +145,6 @@ import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
@ -163,7 +161,6 @@ import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
@ -188,7 +185,6 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import kotlin.Unit;
@ -213,29 +209,28 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private static final int MAX_CONTACTS_ABOVE_FOLD = 5;
private static final int MAX_GROUP_MEMBERSHIPS_ABOVE_FOLD = 5;
private ActionMode actionMode;
private View coordinator;
private RecyclerView list;
private Stub<ReminderView> reminderView;
private Stub<UnreadPaymentsView> paymentNotificationView;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
private ConversationListFilterPullView pullView;
private AppBarLayout pullViewAppBarLayout;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private ActionMode actionMode;
private View coordinator;
private RecyclerView list;
private Stub<ReminderView> reminderView;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
private ConversationListFilterPullView pullView;
private AppBarLayout pullViewAppBarLayout;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private ConversationListAdapter defaultAdapter;
private PagingMappingAdapter<ContactSearchKey> searchAdapter;
private Stub<ViewGroup> megaphoneContainer;
private SnapToTopDataObserver snapToTopDataObserver;
private Drawable archiveDrawable;
private AppForegroundObserver.Listener appForegroundObserver;
private VoiceNoteMediaControllerOwner mediaControllerOwner;
private Stub<FrameLayout> voiceNotePlayerViewStub;
private VoiceNotePlayerView voiceNotePlayerView;
private SignalBottomActionBar bottomActionBar;
private SignalContextMenu activeContextMenu;
private LifecycleDisposable lifecycleDisposable;
private Stub<ViewGroup> megaphoneContainer;
private SnapToTopDataObserver snapToTopDataObserver;
private Drawable archiveDrawable;
private AppForegroundObserver.Listener appForegroundObserver;
private VoiceNoteMediaControllerOwner mediaControllerOwner;
private Stub<FrameLayout> voiceNotePlayerViewStub;
private VoiceNotePlayerView voiceNotePlayerView;
private SignalBottomActionBar bottomActionBar;
private SignalContextMenu activeContextMenu;
private LifecycleDisposable lifecycleDisposable;
protected ConversationListArchiveItemDecoration archiveDecoration;
protected ConversationListItemAnimator itemAnimator;
@ -272,12 +267,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.bindTo(getViewLifecycleOwner());
coordinator = view.findViewById(R.id.coordinator);
list = view.findViewById(R.id.list);
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
reminderView = new Stub<>(view.findViewById(R.id.reminder));
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
fab = view.findViewById(R.id.fab);
cameraFab = view.findViewById(R.id.camera_fab);
@ -416,7 +413,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
});
lifecycleDisposable = new LifecycleDisposable();
conversationListTabsViewModel = new ViewModelProvider(requireActivity()).get(ConversationListTabsViewModel.class);
lifecycleDisposable.bindTo(getViewLifecycleOwner());
@ -441,7 +437,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
bottomActionBar = null;
reminderView = null;
megaphoneContainer = null;
paymentNotificationView = null;
voiceNotePlayerViewStub = null;
fab = null;
cameraFab = null;
@ -512,7 +507,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onPrepareOptionsMenu(Menu menu) {
menu.findItem(R.id.menu_clear_passphrase).setVisible(TextSecurePreferences.isPassphraseLockEnabled(requireContext()));
ConversationFilterRequest request = viewModel.getConversationFilterRequest().getValue();
ConversationFilterRequest request = viewModel.getConversationFilterRequest();
boolean isChatFilterEnabled = request != null && request.getFilter() == ConversationFilter.UNREAD;
menu.findItem(R.id.menu_filter_unread_chats).setVisible(FeatureFlags.chatFilters() && !isChatFilterEnabled);
@ -557,7 +552,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
onMegaphoneChanged(viewModel.getMegaphone().getValue());
onMegaphoneChanged(viewModel.getMegaphone());
}
private ContactSearchConfiguration mapSearchStateToConfiguration(@NonNull ContactSearchState state) {
@ -748,8 +743,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void initializeSearchListener() {
viewModel.getConversationFilterRequest().observe(getViewLifecycleOwner(), this::updateSearchToolbarHint);
viewModel.getConversationFilterRequest().observe(getViewLifecycleOwner(), contactSearchMediator::onConversationFilterRequestChanged);
lifecycleDisposable.add(
viewModel.getFilterRequestState().subscribe(request -> {
updateSearchToolbarHint(request);
contactSearchMediator.onConversationFilterRequestChanged(request);
})
);
requireCallback().getSearchAction().setOnClickListener(v -> {
fadeOutButtonsAndMegaphone(250);
@ -784,7 +783,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
fadeInButtonsAndMegaphone(250);
}
});
updateSearchToolbarHint(Objects.requireNonNull(viewModel.getConversationFilterRequest().getValue()));
updateSearchToolbarHint(Objects.requireNonNull(viewModel.getConversationFilterRequest()));
});
}
@ -855,9 +854,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
if (adapter instanceof ConversationListAdapter) {
viewModel.getPagingController()
.observe(getViewLifecycleOwner(),
controller -> ((ConversationListAdapter) adapter).setPagingController(controller));
((ConversationListAdapter) adapter).setPagingController(viewModel.getController());
}
list.setAdapter(adapter);
@ -884,15 +881,13 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void initializeViewModel() {
ConversationListViewModel.Factory viewModelFactory = new ConversationListViewModel.Factory(isArchived());
viewModel = new ViewModelProvider(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class);
viewModel = new ViewModelProvider(this, (ViewModelProvider.Factory) viewModelFactory).get(ConversationListViewModel.class);
viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged);
viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onConversationListChanged);
viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState);
viewModel.getNotificationProfiles().observe(getViewLifecycleOwner(), profiles -> requireCallback().updateNotificationProfileStatus(profiles));
viewModel.getPipeState().observe(getViewLifecycleOwner(), pipeState -> requireCallback().updateProxyStatus(pipeState));
lifecycleDisposable.add(viewModel.getMegaphoneState().subscribe(this::onMegaphoneChanged));
lifecycleDisposable.add(viewModel.getConversationsState().subscribe(this::onConversationListChanged));
lifecycleDisposable.add(viewModel.getHasNoConversations().subscribe(this::updateEmptyState));
lifecycleDisposable.add(viewModel.getNotificationProfiles().subscribe(profiles -> requireCallback().updateNotificationProfileStatus(profiles)));
lifecycleDisposable.add(viewModel.getWebSocketState().subscribe(pipeState -> requireCallback().updateProxyStatus(pipeState)));
appForegroundObserver = new AppForegroundObserver.Listener() {
@Override
@ -904,12 +899,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onBackground() {}
};
viewModel.getUnreadPaymentsLiveData().observe(getViewLifecycleOwner(), this::onUnreadPaymentsChanged);
viewModel.getSelectedConversations().observe(getViewLifecycleOwner(), conversations -> {
defaultAdapter.setSelectedConversations(conversations);
updateMultiSelectState();
});
lifecycleDisposable.add(
viewModel.getSelectedState().subscribe(conversations -> {
defaultAdapter.setSelectedConversations(conversations);
updateMultiSelectState();
})
);
}
private void onFirstRender() {
@ -943,31 +938,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
});
}
private void onUnreadPaymentsChanged(@NonNull Optional<UnreadPayments> unreadPayments) {
if (unreadPayments.isPresent()) {
paymentNotificationView.get().setListener(new PaymentNotificationListener(unreadPayments.get()));
paymentNotificationView.get().setUnreadPayments(unreadPayments.get());
animatePaymentUnreadStatusIn();
} else {
animatePaymentUnreadStatusOut();
}
}
private void animatePaymentUnreadStatusIn() {
paymentNotificationView.get().setVisibility(View.VISIBLE);
requireCallback().getUnreadPaymentsDot().animate().alpha(1);
}
private void animatePaymentUnreadStatusOut() {
if (paymentNotificationView.resolved()) {
paymentNotificationView.get().setVisibility(View.GONE);
}
requireCallback().getUnreadPaymentsDot().animate().alpha(0);
}
private void onMegaphoneChanged(@Nullable Megaphone megaphone) {
if (megaphone == null || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
private void onMegaphoneChanged(@NonNull Megaphone megaphone) {
if (megaphone == Megaphone.NONE || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
if (megaphoneContainer.resolved()) {
megaphoneContainer.get().setVisibility(View.GONE);
megaphoneContainer.get().removeAllViews();
@ -1598,39 +1570,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
pullViewAppBarLayout.setExpanded(false, true);
}
private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
private final UnreadPayments unreadPayments;
private PaymentNotificationListener(@NonNull UnreadPayments unreadPayments) {
this.unreadPayments = unreadPayments;
}
@Override
public void onOpenPaymentsNotificationClicked() {
UUID paymentId = unreadPayments.getPaymentUuid();
if (paymentId == null) {
goToPaymentsHome();
} else {
goToSinglePayment(paymentId);
}
}
@Override
public void onClosePaymentsNotificationClicked() {
viewModel.onUnreadPaymentsClosed();
}
private void goToPaymentsHome() {
startActivity(new Intent(requireContext(), PaymentsActivity.class));
}
private void goToSinglePayment(@NonNull UUID paymentId) {
startActivity(PaymentsActivity.navigateToPaymentDetails(requireContext(), paymentId));
}
}
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
private static final long SWIPE_ANIMATION_DURATION = 175;

View file

@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.components.emoji.EmojiStrings;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
@ -113,7 +114,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
private LiveRecipient recipient;
private long threadId;
private GlideRequests glideRequests;
private TextView subjectView;
private SimpleEmojiTextView subjectView;
private TypingIndicatorView typingView;
private FromTextView fromView;
private TextView dateView;
@ -170,6 +171,8 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
this.thumbTarget = new GlideLiveDataTarget(thumbSize, thumbSize);
this.searchStyleFactory = () -> new CharacterStyle[] { new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface)), SpanUtil.getBoldSpan() };
this.subjectView.enableSpoilerFiltering();
getLayoutTransition().setDuration(150);
}

View file

@ -1,267 +0,0 @@
package org.thoughtcrime.securesms.conversationlist;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.LiveDataReactiveStreams;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.signal.core.util.logging.Log;
import org.signal.paging.LivePagedData;
import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.search.SearchResult;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.util.paging.Invalidator;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import kotlin.Pair;
class ConversationListViewModel extends ViewModel {
private static final String TAG = Log.tag(ConversationListViewModel.class);
private static boolean coldStart = true;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<ConversationSet> selectedConversations;
private final MutableLiveData<ConversationFilterRequest> conversationFilterRequest;
private final LiveData<ConversationListDataSource> conversationListDataSource;
private final Set<Conversation> internalSelection;
private final LiveData<LivePagedData<Long, Conversation>> pagedData;
private final LiveData<Boolean> hasNoConversations;
private final MegaphoneRepository megaphoneRepository;
private final ThrottledDebouncer updateDebouncer;
private final DatabaseObserver.Observer observer;
private final Invalidator invalidator;
private final CompositeDisposable disposables;
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final UnreadPaymentsRepository unreadPaymentsRepository;
private final NotificationProfilesRepository notificationProfilesRepository;
private int pinnedCount;
private ConversationListViewModel(boolean isArchived) {
this.megaphone = new MutableLiveData<>();
this.internalSelection = new HashSet<>();
this.selectedConversations = new MutableLiveData<>(new ConversationSet());
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
this.unreadPaymentsRepository = new UnreadPaymentsRepository();
this.notificationProfilesRepository = new NotificationProfilesRepository();
this.updateDebouncer = new ThrottledDebouncer(500);
this.invalidator = new Invalidator();
this.disposables = new CompositeDisposable();
this.conversationFilterRequest = new MutableLiveData<>(new ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG));
this.conversationListDataSource = Transformations.map(Transformations.distinctUntilChanged(conversationFilterRequest),
request -> ConversationListDataSource.create(request.getFilter(),
isArchived,
SignalStore.uiHints().canDisplayPullToFilterTip() && request.getSource() == ConversationFilterSource.OVERFLOW));
this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
new PagingConfig.Builder()
.setPageSize(15)
.setBufferPages(2)
.build()));
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
this.observer = () -> {
updateDebouncer.publish(() -> {
LivePagedData<Long, Conversation> data = pagedData.getValue();
if (data == null) {
return;
}
data.getController().onDataInvalidated();
});
};
this.hasNoConversations = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(conversationFilterRequest, getConversationList(), Pair::new), filterAndData -> {
pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(ConversationFilter.OFF);
if (filterAndData.getSecond().size() > 0) {
return false;
} else {
return SignalDatabase.threads().getArchivedConversationListCount(filterAndData.getFirst().getFilter()) == 0;
}
});
ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(observer);
}
public LiveData<Boolean> hasNoConversations() {
return hasNoConversations;
}
@NonNull LiveData<Megaphone> getMegaphone() {
return megaphone;
}
@NonNull LiveData<List<Conversation>> getConversationList() {
return Transformations.switchMap(pagedData, LivePagedData::getData);
}
@NonNull LiveData<PagingController<Long>> getPagingController() {
return Transformations.map(pagedData, LivePagedData::getController);
}
@NonNull LiveData<List<NotificationProfile>> getNotificationProfiles() {
final Observable<List<NotificationProfile>> activeProfile = Observable.combineLatest(Observable.interval(0, 30, TimeUnit.SECONDS), notificationProfilesRepository.getProfiles(), (interval, profiles) -> profiles);
return LiveDataReactiveStreams.fromPublisher(activeProfile.toFlowable(BackpressureStrategy.LATEST));
}
@NonNull LiveData<WebSocketConnectionState> getPipeState() {
return LiveDataReactiveStreams.fromPublisher(ApplicationDependencies.getSignalWebSocket().getWebSocketState().toFlowable(BackpressureStrategy.LATEST));
}
@NonNull LiveData<Optional<UnreadPayments>> getUnreadPaymentsLiveData() {
return unreadPaymentsLiveData;
}
@NonNull LiveData<ConversationFilterRequest> getConversationFilterRequest() {
return conversationFilterRequest;
}
public int getPinnedCount() {
return pinnedCount;
}
void onVisible() {
megaphoneRepository.getNextMegaphone(megaphone::postValue);
if (!coldStart) {
ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners();
}
coldStart = false;
}
@NonNull Set<Conversation> currentSelectedConversations() {
return internalSelection;
}
@NonNull LiveData<ConversationSet> getSelectedConversations() {
return selectedConversations;
}
void startSelection(@NonNull Conversation conversation) {
setSelection(Collections.singleton(conversation));
}
void endSelection() {
setSelection(Collections.emptySet());
}
void toggleConversationSelected(@NonNull Conversation conversation) {
Set<Conversation> newSelection = new HashSet<>(internalSelection);
if (newSelection.contains(conversation)) {
newSelection.remove(conversation);
} else {
newSelection.add(conversation);
}
setSelection(newSelection);
}
void setFiltered(boolean isFiltered, @NonNull ConversationFilterSource conversationFilterSource) {
if (isFiltered) {
conversationFilterRequest.setValue(new ConversationFilterRequest(ConversationFilter.UNREAD, conversationFilterSource));
} else {
conversationFilterRequest.setValue(new ConversationFilterRequest(ConversationFilter.OFF, conversationFilterSource));
}
}
private void setSelection(@NonNull Collection<Conversation> newSelection) {
internalSelection.clear();
internalSelection.addAll(newSelection);
selectedConversations.setValue(new ConversationSet(internalSelection));
}
void onSelectAllClick() {
ConversationListDataSource dataSource = conversationListDataSource.getValue();
if (dataSource == null) {
return;
}
disposables.add(
Single.fromCallable(() -> dataSource.load(0, dataSource.size(), disposables::isDisposed))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::setSelection)
);
}
void onMegaphoneCompleted(@NonNull Megaphones.Event event) {
megaphone.postValue(null);
megaphoneRepository.markFinished(event);
}
void onMegaphoneSnoozed(@NonNull Megaphones.Event event) {
megaphoneRepository.markSeen(event);
megaphone.postValue(null);
}
void onMegaphoneVisible(@NonNull Megaphone visible) {
megaphoneRepository.markVisible(visible.getEvent());
}
void onUnreadPaymentsClosed() {
unreadPaymentsRepository.markAllPaymentsSeen();
}
@Override
protected void onCleared() {
invalidator.invalidate();
disposables.dispose();
updateDebouncer.clear();
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer);
}
public static class Factory extends ViewModelProvider.NewInstanceFactory {
private final boolean isArchived;
public Factory(boolean isArchived) {
this.isArchived = isArchived;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ConversationListViewModel(isArchived));
}
}
}

View file

@ -0,0 +1,215 @@
package org.thoughtcrime.securesms.conversationlist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
import org.thoughtcrime.securesms.conversationlist.model.Conversation
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet
import org.thoughtcrime.securesms.database.RxDatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import java.util.concurrent.TimeUnit
class ConversationListViewModel(
private val isArchived: Boolean,
private val megaphoneRepository: MegaphoneRepository = ApplicationDependencies.getMegaphoneRepository(),
private val notificationProfilesRepository: NotificationProfilesRepository = NotificationProfilesRepository()
) : ViewModel() {
companion object {
private var coldStart = true
}
private val disposables: CompositeDisposable = CompositeDisposable()
private val store = RxStore(ConversationListState()).addTo(disposables)
private val conversationListDataSource: Flowable<ConversationListDataSource>
private val pagingConfig = PagingConfig.Builder()
.setPageSize(15)
.setBufferPages(2)
.build()
val conversationsState: Flowable<List<Conversation>> = store.mapDistinctForUi { it.conversations }
val megaphoneState: Flowable<Megaphone> = store.mapDistinctForUi { it.megaphone }
val selectedState: Flowable<ConversationSet> = store.mapDistinctForUi { it.selectedConversations }
val filterRequestState: Flowable<ConversationFilterRequest> = store.mapDistinctForUi { it.filterRequest }
val hasNoConversations: Flowable<Boolean>
val controller = ProxyPagingController<Long>()
val conversationFilterRequest: ConversationFilterRequest
get() = store.state.filterRequest
val megaphone: Megaphone
get() = store.state.megaphone
val pinnedCount: Int
get() = store.state.pinnedCount
val webSocketState: Observable<WebSocketConnectionState>
get() = ApplicationDependencies.getSignalWebSocket().webSocketState
@get:JvmName("currentSelectedConversations")
val currentSelectedConversations: Set<Conversation>
get() = store.state.internalSelection
init {
conversationListDataSource = store
.stateFlowable
.subscribeOn(Schedulers.io())
.map { it.filterRequest }
.distinctUntilChanged()
.map {
ConversationListDataSource.create(
it.filter,
isArchived,
SignalStore.uiHints().canDisplayPullToFilterTip() && it.source === ConversationFilterSource.OVERFLOW
)
}
.replay(1)
.refCount()
val pagedData = conversationListDataSource
.map { PagedData.createForObservable(it, pagingConfig) }
.doOnNext { controller.set(it.controller) }
.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) }
store.update(pagedData) { conversations, state -> state.copy(conversations = conversations) }
.addTo(disposables)
RxDatabaseObserver
.conversationList
.throttleLatest(500, TimeUnit.MILLISECONDS)
.subscribe { controller.onDataInvalidated() }
.addTo(disposables)
val pinnedCount = RxDatabaseObserver
.conversationList
.map { SignalDatabase.threads.getPinnedConversationListCount(ConversationFilter.OFF) }
.distinctUntilChanged()
store.update(pinnedCount) { pinned, state -> state.copy(pinnedCount = pinned) }
.addTo(disposables)
hasNoConversations = store
.stateFlowable
.map { it.filterRequest to it.conversations }
.distinctUntilChanged()
.map { (filterRequest, conversations) ->
if (conversations.isNotEmpty()) {
false
} else {
SignalDatabase.threads.getArchivedConversationListCount(filterRequest.filter) == 0
}
}
}
override fun onCleared() {
disposables.dispose()
super.onCleared()
}
fun onVisible() {
megaphoneRepository.getNextMegaphone { next ->
store.update { it.copy(megaphone = next ?: Megaphone.NONE) }
}
if (!coldStart) {
ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners()
}
coldStart = false
}
fun startSelection(conversation: Conversation) {
setSelection(setOf(conversation))
}
fun endSelection() {
setSelection(emptySet())
}
fun onSelectAllClick() {
conversationListDataSource
.subscribeOn(Schedulers.io())
.firstOrError()
.map { dataSource -> dataSource.load(0, dataSource.size()) { disposables.isDisposed } }
.subscribe { newSelection -> setSelection(newSelection) }
.addTo(disposables)
}
fun toggleConversationSelected(conversation: Conversation) {
val newSelection: MutableSet<Conversation> = store.state.internalSelection.toMutableSet()
if (newSelection.contains(conversation)) {
newSelection.remove(conversation)
} else {
newSelection.add(conversation)
}
setSelection(newSelection)
}
fun setFiltered(isFiltered: Boolean, conversationFilterSource: ConversationFilterSource) {
store.update {
it.copy(filterRequest = ConversationFilterRequest(if (isFiltered) ConversationFilter.UNREAD else ConversationFilter.OFF, conversationFilterSource))
}
}
fun onMegaphoneCompleted(event: Megaphones.Event) {
store.update { it.copy(megaphone = Megaphone.NONE) }
megaphoneRepository.markFinished(event)
}
fun onMegaphoneSnoozed(event: Megaphones.Event) {
megaphoneRepository.markSeen(event)
store.update { it.copy(megaphone = Megaphone.NONE) }
}
fun onMegaphoneVisible(visible: Megaphone) {
megaphoneRepository.markVisible(visible.event)
}
fun getNotificationProfiles(): Flowable<List<NotificationProfile>> {
return notificationProfilesRepository.getProfiles()
.observeOn(AndroidSchedulers.mainThread())
}
private fun setSelection(newSelection: Collection<Conversation>) {
store.update {
val selection = newSelection.toSet()
it.copy(internalSelection = selection, selectedConversations = ConversationSet(selection))
}
}
private data class ConversationListState(
val conversations: List<Conversation> = emptyList(),
val megaphone: Megaphone = Megaphone.NONE,
val selectedConversations: ConversationSet = ConversationSet(),
val internalSelection: Set<Conversation> = emptySet(),
val filterRequest: ConversationFilterRequest = ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG),
val pinnedCount: Int = 0
)
class Factory(private val isArchived: Boolean) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationListViewModel(isArchived))!!
}
}
}

View file

@ -37,6 +37,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.json.JSONArray;
import org.json.JSONException;
import org.signal.core.util.CursorUtil;
import org.signal.core.util.SQLiteDatabaseExtensionsKt;
import org.signal.core.util.SetUtil;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.StreamUtil;
@ -1436,6 +1437,20 @@ public class AttachmentTable extends DatabaseTable {
return EncryptedMediaDataSource.createFor(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length);
}
public void duplicateAttachmentsForMessage(long destinationMessageId, long sourceMessageId, Collection<Long> excludedIds) {
SQLiteDatabaseExtensionsKt.withinTransaction(getWritableDatabase(), db -> {
db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM " + TABLE_NAME + " WHERE " + MMS_ID + " = ?", SqlUtil.buildArgs(sourceMessageId));
List<SqlUtil.Query> queries = SqlUtil.buildCollectionQuery(ROW_ID, excludedIds);
for (SqlUtil.Query query : queries) {
db.delete("tmp_part", query.getWhere(), query.getWhereArgs());
}
db.execSQL("UPDATE tmp_part SET " + ROW_ID + " = NULL, " + MMS_ID + " = ?", SqlUtil.buildArgs(destinationMessageId));
db.execSQL("INSERT INTO " + TABLE_NAME + " SELECT * FROM tmp_part");
db.execSQL("DROP TABLE tmp_part");
return 0;
});
}
@VisibleForTesting
static class DataInfo {
private final File file;

View file

@ -19,6 +19,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY
)
""".trimIndent()
"""
}
}

View file

@ -20,6 +20,7 @@ import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireObject
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toSingleLine
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.ringrtc.CallId
@ -76,11 +77,13 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
UNIQUE ($CALL_ID, $PEER, $CALL_LINK) ON CONFLICT FAIL,
CHECK (($PEER IS NULL AND $CALL_LINK IS NOT NULL) OR ($PEER IS NOT NULL AND $CALL_LINK IS NULL))
)
""".trimIndent()
"""
val CREATE_INDEXES = arrayOf(
"CREATE INDEX call_call_id_index ON $TABLE_NAME ($CALL_ID)",
"CREATE INDEX call_message_id_index ON $TABLE_NAME ($MESSAGE_ID)"
"CREATE INDEX call_message_id_index ON $TABLE_NAME ($MESSAGE_ID)",
"CREATE INDEX call_call_link_index ON $TABLE_NAME ($CALL_LINK)",
"CREATE INDEX call_peer_index ON $TABLE_NAME ($PEER)"
)
}
@ -90,7 +93,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
val expiresIn = Recipient.resolved(peer).expiresInMillis
writableDatabase.withinTransaction {
val result = SignalDatabase.messages.insertCallLog(peer, messageType, timestamp, expiresIn, unread)
val result = SignalDatabase.messages.insertCallLog(peer, messageType, timestamp, direction == Direction.OUTGOING, expiresIn, unread)
val values = contentValuesOf(
CALL_ID to callId,
@ -106,7 +109,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (!unread && expiresIn > 0) {
SignalDatabase.messages.markExpireStarted(result.messageId, timestamp)
ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(result.messageId, timestamp, expiresIn)
ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(result.messageId, true, timestamp, expiresIn)
}
}
@ -142,7 +145,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (!unread && expiresIn > 0) {
val timestampOrNow = timestamp ?: System.currentTimeMillis()
SignalDatabase.messages.markExpireStarted(call.messageId, timestampOrNow)
ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(call.messageId, timestampOrNow, expiresIn)
ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(call.messageId, true, timestampOrNow, expiresIn)
}
}
@ -537,7 +540,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
fun isRingCancelled(ringId: Long, groupRecipientId: RecipientId): Boolean {
val call = getCallById(ringId, CallConversationId.Peer(groupRecipientId)) ?: return false
return call.event != Event.RINGING
return call.event != Event.RINGING && call.event != Event.GENERIC_GROUP_CALL
}
private fun handleGroupRingState(
@ -564,6 +567,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
else -> Log.w(TAG, "Received a REQUESTED ring event while in ${call.event}. Ignoring.")
}
}
RingUpdate.EXPIRED_REQUEST, RingUpdate.CANCELLED_BY_RINGER -> {
when (call.event) {
Event.GENERIC_GROUP_CALL, Event.RINGING -> updateEventFromRingState(ringId, Event.MISSED, ringerRecipient)
@ -571,6 +575,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
else -> Unit
}
}
RingUpdate.BUSY_LOCALLY, RingUpdate.BUSY_ON_ANOTHER_DEVICE -> {
when (call.event) {
Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED)
@ -578,9 +583,11 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
else -> Log.w(TAG, "Received a busy event we can't process. Ignoring.")
}
}
RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE -> {
updateEventFromRingState(ringId, Event.ACCEPTED)
}
RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> {
when (call.event) {
Event.RINGING, Event.MISSED -> updateEventFromRingState(ringId, Event.DECLINED)
@ -597,14 +604,17 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
Log.w(TAG, "Missed original ring request for $ringId")
Event.ACCEPTED
}
RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> {
Log.w(TAG, "Missed original ring request for $ringId")
Event.DECLINED
}
RingUpdate.BUSY_LOCALLY, RingUpdate.BUSY_ON_ANOTHER_DEVICE -> {
Log.w(TAG, "Missed original ring request for $ringId")
Event.MISSED
}
RingUpdate.CANCELLED_BY_RINGER -> {
Log.w(TAG, "Missed original ring request for $ringId")
Event.MISSED
@ -722,10 +732,72 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
updateCallEventDeletionTimestamps()
}
fun deleteAllCallEventsExcept(callRowIds: Set<Long>) {
val messageIds = getMessageIds(callRowIds)
SignalDatabase.messages.deleteAllCallUpdatesExcept(messageIds)
updateCallEventDeletionTimestamps()
fun deleteAllCallEventsExcept(callRowIds: Set<Long>, missedOnly: Boolean) {
val callFilter = if (missedOnly) {
"$EVENT = ${Event.serialize(Event.MISSED)} AND $DELETION_TIMESTAMP = 0"
} else {
"$DELETION_TIMESTAMP = 0"
}
if (callRowIds.isEmpty()) {
val threadIds = writableDatabase.withinTransaction { db ->
val ids = db.select(MessageTable.THREAD_ID)
.from(MessageTable.TABLE_NAME)
.where(
"""
${MessageTable.ID} IN (
SELECT $MESSAGE_ID FROM $TABLE_NAME
WHERE $callFilter
)
""".toSingleLine()
)
.run()
.readToList { it.requireLong(MessageTable.THREAD_ID) }
db.delete(MessageTable.TABLE_NAME)
.where(
"""
${MessageTable.ID} IN (
SELECT $MESSAGE_ID FROM $TABLE_NAME
WHERE $callFilter
)
""".toSingleLine()
)
.run()
ids.toSet()
}
threadIds.forEach {
SignalDatabase.threads.update(
threadId = it,
unarchive = false,
allowDeletion = true
)
}
notifyConversationListeners(threadIds)
notifyConversationListListeners()
updateCallEventDeletionTimestamps()
} else {
writableDatabase.withinTransaction { db ->
SqlUtil.buildCollectionQuery(
column = ID,
values = callRowIds,
prefix = "$callFilter AND",
collectionOperator = SqlUtil.CollectionOperator.NOT_IN
).forEach { query ->
val messageIds = db.select(MESSAGE_ID)
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToList { it.requireLong(MESSAGE_ID) }
.toSet()
SignalDatabase.messages.deleteCallUpdates(messageIds)
updateCallEventDeletionTimestamps()
}
}
}
}
@Discouraged("Using this method is generally considered an error. Utilize other deletion methods instead of this.")
@ -780,10 +852,16 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
${RecipientTable.TABLE_NAME}.${RecipientTable.PHONE} GLOB ? OR
${RecipientTable.TABLE_NAME}.${RecipientTable.EMAIL} GLOB ?
)
""".trimIndent()
"""
SqlUtil.buildQuery(selection, 0, 0, glob, glob, glob, glob)
} else {
SqlUtil.buildQuery("")
SqlUtil.buildQuery(
"""
${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = ? AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = ?
""",
0,
0
)
}
val offsetLimit = if (limit > 0) {
@ -795,7 +873,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
val projection = if (isCount) {
"COUNT(*),"
} else {
"p.$ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, p.$TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, children, in_period, ${MessageTable.DATE_RECEIVED}, ${MessageTable.BODY},"
"p.$ID, p.$TIMESTAMP, $EVENT, $DIRECTION, $PEER, p.$TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, children, in_period, ${MessageTable.BODY},"
}
//language=sql
@ -803,6 +881,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
SELECT $projection
LOWER(
COALESCE(
NULLIF(${GroupTable.TABLE_NAME}.${GroupTable.TITLE}, ''),
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''),
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''),
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''),
@ -870,9 +949,11 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
) p
INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $PEER
INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $MESSAGE_ID
LEFT JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE true_parent = p.$ID ${if (queryClause.where.isNotEmpty()) "AND ${queryClause.where}" else ""}
ORDER BY p.$TIMESTAMP DESC
$offsetLimit
""".trimIndent()
"""
return readableDatabase.query(
statement,
@ -891,11 +972,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
return getCallsCursor(false, offset, limit, searchTerm, filter).readToList { cursor ->
val call = Call.deserialize(cursor)
val recipient = Recipient.resolved(call.peer)
val date = cursor.requireLong(MessageTable.DATE_RECEIVED)
val groupCallDetails = GroupCallUpdateDetailsUtil.parse(cursor.requireString(MessageTable.BODY))
Log.d(TAG, "${cursor.requireNonNullString("in_period")}")
val children = cursor.requireNonNullString("children")
.split(',')
.map { it.toLong() }
@ -912,7 +990,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
CallLogRow.Call(
record = call,
peer = recipient,
date = date,
date = call.timestamp,
groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails),
children = actualChildren.toSet()
)

View file

@ -23,7 +23,7 @@ class ChatColorsTable(context: Context, databaseHelper: SignalDatabase) : Databa
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$CHAT_COLORS BLOB
)
""".trimIndent()
"""
}
fun getById(chatColorsId: ChatColors.Id): ChatColors {

View file

@ -41,7 +41,7 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign
val CREATE_TABLE: Array<String> = arrayOf(ListTable.CREATE_TABLE, MembershipTable.CREATE_TABLE)
@JvmField
val CREATE_INDEXES: Array<String> = arrayOf(MembershipTable.CREATE_INDEX)
val CREATE_INDEXES: Array<String> = MembershipTable.CREATE_INDEXES
const val RECIPIENT_ID = ListTable.RECIPIENT_ID
const val DISTRIBUTION_ID = ListTable.DISTRIBUTION_ID
@ -123,7 +123,10 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign
)
"""
const val CREATE_INDEX = "CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON $TABLE_NAME ($LIST_ID, $RECIPIENT_ID, $PRIVACY_MODE)"
val CREATE_INDEXES = arrayOf(
"CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON $TABLE_NAME ($LIST_ID, $RECIPIENT_ID, $PRIVACY_MODE)",
"CREATE INDEX distribution_list_member_recipient_id ON $TABLE_NAME ($RECIPIENT_ID)"
)
}
/**

Some files were not shown because too many files have changed in this diff Show more