mirror of
https://github.com/mollyim/mollyim-insider-android.git
synced 2025-05-13 05:40:53 +01:00
Merge tag 'v6.19.8' into molly-6.19
This commit is contained in:
commit
ce72ede47f
383 changed files with 15105 additions and 4836 deletions
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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", "")
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
"""
|
||||
}
|
||||
|
|
|
@ -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) };
|
||||
|
||||
|
|
|
@ -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, ", ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!!)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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) },
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))!!
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -19,6 +19,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
|||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY
|
||||
)
|
||||
""".trimIndent()
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue