mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-03 00:42:58 +01:00
Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fca0c69ac | ||
|
|
70eb4ca2a1 | ||
|
|
9d9e30725e | ||
|
|
ff9585ec7d | ||
|
|
a418c2750a | ||
|
|
9581994050 | ||
|
|
316d0e67c5 | ||
|
|
503bf04ec5 | ||
|
|
d6b76936dd | ||
|
|
c53d16717b | ||
|
|
2c747daa50 | ||
|
|
0b2d3edcce | ||
|
|
955bcde062 | ||
|
|
a91aa72fb4 | ||
|
|
163ece75b2 | ||
|
|
a8fb5f2598 | ||
|
|
3a62ad67e1 | ||
|
|
48f4e1ddc6 | ||
|
|
c37bb96aab | ||
|
|
a2057e20d2 | ||
|
|
577e05eb51 | ||
|
|
65a30cf2a7 | ||
|
|
121f0c6134 | ||
|
|
7d1897a9d2 | ||
|
|
415dbd1b61 | ||
|
|
cfc1c35203 | ||
|
|
911d7f3be8 | ||
|
|
c06944da13 | ||
|
|
b6dd4a3579 | ||
|
|
b057e145c5 | ||
|
|
772ad3b929 | ||
|
|
46681868d3 | ||
|
|
75795bd7d5 | ||
|
|
1908723fbe | ||
|
|
549992c08a | ||
|
|
845704b9fe | ||
|
|
ba03ca5e0c | ||
|
|
92a9f12b58 | ||
|
|
3437ac63bb | ||
|
|
d798a35c38 | ||
|
|
01b56995d9 | ||
|
|
3f190efb4e | ||
|
|
bb6b149c2e | ||
|
|
65b96fff16 | ||
|
|
0b8e8a7b2f | ||
|
|
a8a6fec19d | ||
|
|
a3fce4c149 | ||
|
|
85265412da | ||
|
|
e636a94de0 | ||
|
|
08509f6693 | ||
|
|
d28fc98cfd | ||
|
|
f584ef1d72 | ||
|
|
67a6df57c8 | ||
|
|
fadbb0adc5 | ||
|
|
58774033b7 | ||
|
|
66f0470960 | ||
|
|
68137cb66f | ||
|
|
4d6cacdb3d | ||
|
|
cf862af3ca | ||
|
|
a8d106a292 | ||
|
|
6155140de4 | ||
|
|
a4637248e8 | ||
|
|
8c4470a27e | ||
|
|
071fbfd916 | ||
|
|
1968438ebb | ||
|
|
7b31383b88 | ||
|
|
093a79045d | ||
|
|
e4928b0084 | ||
|
|
03420cf501 | ||
|
|
541b4674a8 | ||
|
|
6e108a03d1 | ||
|
|
c9dd332abd | ||
|
|
7e605fb6de | ||
|
|
fa2b0aedb0 | ||
|
|
402f49edd9 | ||
|
|
caf2e555dd | ||
|
|
32dc36d937 | ||
|
|
771d49bfa8 | ||
|
|
70dc78601a | ||
|
|
b4d781ddbb | ||
|
|
9c29601b55 | ||
|
|
28c37cb3ac | ||
|
|
bd121e47c8 | ||
|
|
7428e1e2ea | ||
|
|
376cb926b0 | ||
|
|
4ed0056d2a | ||
|
|
177ef8a555 | ||
|
|
7244a1f52f | ||
|
|
8d311923c1 | ||
|
|
9359d56880 | ||
|
|
3214200188 | ||
|
|
841ab7f983 | ||
|
|
53b3728432 | ||
|
|
cf9f98efc9 | ||
|
|
b5c666a1f4 | ||
|
|
b1954a509c | ||
|
|
c2c91cfe42 | ||
|
|
cccbec5744 | ||
|
|
4c89b20fad | ||
|
|
2328fa3e88 | ||
|
|
e19d4624c1 | ||
|
|
345f58ed48 | ||
|
|
4c14ce3937 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ maps.key
|
||||
kls_database.db
|
||||
.kotlin
|
||||
lefthook-local.yml
|
||||
sample-videos/
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
@@ -20,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1652
|
||||
val canonicalVersionName = "8.0.0"
|
||||
val canonicalVersionCode = 1658
|
||||
val canonicalVersionName = "8.1.1"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -516,7 +520,7 @@ android {
|
||||
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
|
||||
|
||||
variant.outputs.forEach { output ->
|
||||
output.versionName.set(tag)
|
||||
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
|
||||
output.versionCode.set(nightlyVersionCode)
|
||||
}
|
||||
}
|
||||
@@ -567,7 +571,8 @@ android {
|
||||
applicationVariants.configureEach {
|
||||
outputs.configureEach {
|
||||
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
|
||||
outputFileName = outputFileName.replace(".apk", "-$versionName.apk")
|
||||
val fileVersionName = versionName.substringBefore(" |")
|
||||
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -806,6 +811,16 @@ fun getNightlyBuildNumber(tag: String?): Int {
|
||||
return match?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
fun getLastCommitDateTimeUtc(): String {
|
||||
val timestamp = providers.exec {
|
||||
commandLine("git", "log", "-1", "--pretty=format:%ct")
|
||||
}.standardOutput.asText.get().trim().toLong()
|
||||
val instant = Instant.ofEpochSecond(timestamp)
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d '@' HH:mm 'UTC'", Locale.US)
|
||||
.withZone(ZoneOffset.UTC)
|
||||
return formatter.format(instant)
|
||||
}
|
||||
|
||||
fun getMapsKey(): String {
|
||||
return providers
|
||||
.gradleProperty("mapsKey")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -71,6 +71,11 @@ class ArchiveImportExportTests {
|
||||
runTests { it.startsWith("chat_folder_") }
|
||||
}
|
||||
|
||||
// @Test
|
||||
fun chatItemAdminDelete() {
|
||||
runTests { it.startsWith("chat_item_admin_deleted_") }
|
||||
}
|
||||
|
||||
// @Test
|
||||
fun chatItemContactMessage() {
|
||||
runTests { it.startsWith("chat_item_contact_message_") }
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.database.FakeMessageRecords
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
|
||||
@@ -208,7 +209,7 @@ class V2ConversationItemShapeTest {
|
||||
private val nextMessage: MessageRecord? = null
|
||||
) : V2ConversationContext {
|
||||
|
||||
private val colorizer = Colorizer()
|
||||
private val colorizer = ColorizerV2()
|
||||
|
||||
override val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
|
||||
override val lifecycle: Lifecycle = LifecycleRegistry(this)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EditMessageRevisionTest {
|
||||
|
||||
@get:Rule
|
||||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var senderId: RecipientId
|
||||
private var threadId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val senderAci = ACI.from(UUID.randomUUID())
|
||||
senderId = SignalDatabase.recipients.getOrInsertFromServiceId(senderAci)
|
||||
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(senderId, false, ThreadTable.DistributionTypes.DEFAULT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleEditSetsLatestRevisionIdOnOriginal() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(editId)
|
||||
assertThat(getLatestRevisionId(editId)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleEditOnlyLatestRevisionAppearsInNotificationState() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(listOf(editId), notificationIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiEditSetsLatestRevisionIdOnAllPreviousRevisions() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit1Id)
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNull()
|
||||
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiEditOnlyLatestRevisionAppearsInNotificationState() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals("Only the latest revision should appear in notification state", listOf(edit2Id), notificationIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readSyncThenMultipleEditsDoNotCreateOrphanedUnreadRevisions() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
markAsRead(originalId)
|
||||
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(
|
||||
"No notifications should appear after edits to a message that was already read via sync",
|
||||
emptyList<Long>(),
|
||||
notificationIds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readSyncOnLatestRevisionThenSecondEditDoesNotCreateOrphanedNotification() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
|
||||
// Read sync updates the latestRevisionId (edit1), not the original
|
||||
markAsRead(edit1Id)
|
||||
assertEquals("No notifications after read sync on edited message", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(
|
||||
"Only the latest revision or no revisions should appear depending on read state",
|
||||
notificationIds.filter { it != edit2Id },
|
||||
emptyList<Long>()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tripleEditCorrectlyChainsAllRevisions() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
val edit3Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1003)
|
||||
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit3Id)
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit3Id)
|
||||
assertThat(getLatestRevisionId(edit2Id)).isNotNull().isEqualTo(edit3Id)
|
||||
assertThat(getLatestRevisionId(edit3Id)).isNull()
|
||||
|
||||
assertEquals(listOf(edit3Id), getNotificationStateMessageIds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiEditWithReadSyncBetweenEditsNotificationDismissedAndStaysDismissed() {
|
||||
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
|
||||
|
||||
assertEquals("Original unread message should be in notification state", 1, getNotificationStateMessageIds().size)
|
||||
|
||||
markAsReadAndNotified(originalId)
|
||||
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
|
||||
assertEquals("No notifications after first edit (original was read)", 0, getNotificationStateMessageIds().size)
|
||||
|
||||
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
|
||||
|
||||
val notificationIds = getNotificationStateMessageIds()
|
||||
assertEquals(
|
||||
"No notifications should appear - message was read via sync before edits arrived",
|
||||
emptyList<Long>(),
|
||||
notificationIds
|
||||
)
|
||||
|
||||
// Verify revision chain integrity
|
||||
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
|
||||
val edit1Id = edit2Id - 1 // edit1 was inserted right before edit2
|
||||
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
|
||||
assertThat(getLatestRevisionId(edit2Id)).isNull()
|
||||
}
|
||||
|
||||
private fun insertOriginalMessage(sentTimeMillis: Long): Long {
|
||||
val message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = senderId,
|
||||
sentTimeMillis = sentTimeMillis,
|
||||
serverTimeMillis = sentTimeMillis,
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
body = "original message"
|
||||
)
|
||||
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* The target is always retrieved via [MessageTable.getMessageFor] using the original sent
|
||||
* timestamp — this matches what [EditMessageProcessor] does and means targetMessage.id
|
||||
* is always the original message's row ID.
|
||||
*/
|
||||
private fun insertEdit(originalSentTimestamp: Long, editSentTimeMillis: Long): Long {
|
||||
val targetMessage = SignalDatabase.messages.getMessageFor(originalSentTimestamp, senderId) as MmsMessageRecord
|
||||
|
||||
val editMessage = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = senderId,
|
||||
sentTimeMillis = editSentTimeMillis,
|
||||
serverTimeMillis = editSentTimeMillis,
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
body = "edited at $editSentTimeMillis"
|
||||
)
|
||||
return SignalDatabase.messages.insertEditMessageInbox(editMessage, targetMessage).get().messageId
|
||||
}
|
||||
|
||||
private fun getLatestRevisionId(messageId: Long): Long? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.query(MessageTable.TABLE_NAME, arrayOf(MessageTable.LATEST_REVISION_ID), "${MessageTable.ID} = ?", arrayOf(messageId.toString()), null, null, null)
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val idx = cursor.getColumnIndexOrThrow(MessageTable.LATEST_REVISION_ID)
|
||||
if (cursor.isNull(idx)) null else cursor.getLong(idx)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNotificationStateMessageIds(): List<Long> {
|
||||
return SignalDatabase.messages.getMessagesForNotificationState(emptyList()).use { cursor ->
|
||||
val ids = mutableListOf<Long>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(CursorUtil.requireLong(cursor, MessageTable.ID))
|
||||
}
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
private fun markAsRead(messageId: Long) {
|
||||
SignalDatabase.rawDatabase.execSQL(
|
||||
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1 WHERE ${MessageTable.ID} = ?",
|
||||
arrayOf(messageId)
|
||||
)
|
||||
}
|
||||
|
||||
private fun markAsReadAndNotified(messageId: Long) {
|
||||
SignalDatabase.rawDatabase.execSQL(
|
||||
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1, ${MessageTable.NOTIFIED} = 1 WHERE ${MessageTable.ID} = ?",
|
||||
arrayOf(messageId)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ class StorySendTableTest {
|
||||
@Test
|
||||
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
|
||||
|
||||
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
|
||||
|
||||
@@ -287,7 +287,7 @@ class StorySendTableTest {
|
||||
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import okio.ByteString
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.AliceClient
|
||||
import org.thoughtcrime.securesms.testing.BobClient
|
||||
import org.thoughtcrime.securesms.testing.Entry
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.awaitFor
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import android.util.Log as AndroidLog
|
||||
|
||||
/**
|
||||
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
|
||||
*/
|
||||
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageProcessingPerformanceTest {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
|
||||
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
|
||||
|
||||
private val DECRYPTION_TIME_PATTERN = Pattern.compile("^Decrypted (?<count>\\d+) envelopes in (?<duration>\\d+) ms.*$")
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private val trustRoot: ECKeyPair = ECKeyPair.generate()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(SealedSenderAccessUtil::class)
|
||||
every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
|
||||
|
||||
mockkObject(MessageContentProcessor)
|
||||
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
unmockkStatic(SealedSenderAccessUtil::class)
|
||||
unmockkStatic(MessageContentProcessor::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformance() {
|
||||
val aliceClient = AliceClient(
|
||||
serviceId = harness.self.requireServiceId(),
|
||||
e164 = harness.self.requireE164(),
|
||||
trustRoot = trustRoot
|
||||
)
|
||||
|
||||
val bob = Recipient.resolved(harness.others[0])
|
||||
val bobClient = BobClient(
|
||||
serviceId = bob.requireServiceId(),
|
||||
e164 = bob.requireE164(),
|
||||
identityKeyPair = harness.othersKeys[0],
|
||||
trustRoot = trustRoot,
|
||||
profileKey = ProfileKey(bob.profileKey)
|
||||
)
|
||||
|
||||
// Send the initial messages to get past the prekey phase
|
||||
establishSession(aliceClient, bobClient, bob)
|
||||
|
||||
// Have Bob generate N messages that will be received by Alice
|
||||
val messageCount = 100
|
||||
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
|
||||
val firstTimestamp = envelopes.first().timestamp
|
||||
val lastTimestamp = envelopes.last().timestamp ?: 0
|
||||
|
||||
// Inject the envelopes into the websocket
|
||||
// TODO: mock websocket messages
|
||||
|
||||
// Wait until they've all been fully decrypted + processed
|
||||
harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
|
||||
.awaitFor(1.minutes)
|
||||
|
||||
harness.inMemoryLogger.flush()
|
||||
|
||||
// Process logs for timing data
|
||||
val entries = harness.inMemoryLogger.entries()
|
||||
|
||||
// Calculate decryption average
|
||||
val totalDecryptDuration: Long = entries
|
||||
.mapNotNull { entry -> entry.message?.let { DECRYPTION_TIME_PATTERN.matcher(it) } }
|
||||
.filter { it.matches() }
|
||||
.drop(1) // Ignore the first message, which represents the prekey exchange
|
||||
.sumOf { it.group("duration")!!.toLong() }
|
||||
|
||||
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / messageCount.toFloat()}ms")
|
||||
|
||||
// Calculate MessageContentProcessor
|
||||
|
||||
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
|
||||
val iterator = takeLast.iterator()
|
||||
var processCount = 0L
|
||||
var processDuration = 0L
|
||||
while (iterator.hasNext()) {
|
||||
val start = iterator.next()
|
||||
val end = iterator.next()
|
||||
processCount++
|
||||
processDuration += end.timestamp - start.timestamp
|
||||
}
|
||||
|
||||
AndroidLog.w(TAG, "MessageContentProcessor.process: Average runtime: ${processDuration.toFloat() / processCount.toFloat()}ms")
|
||||
|
||||
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
|
||||
|
||||
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
|
||||
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
|
||||
|
||||
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
|
||||
val messagePerSecond = messageCount.toFloat() / duration
|
||||
|
||||
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
|
||||
}
|
||||
|
||||
private fun establishSession(aliceClient: AliceClient, bobClient: BobClient, bob: Recipient) {
|
||||
// Send message from Bob to Alice (self)
|
||||
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
|
||||
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
|
||||
|
||||
val aliceProcessFirstMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
|
||||
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
|
||||
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
|
||||
|
||||
// Send message from Alice to Bob
|
||||
val aliceNow = System.currentTimeMillis()
|
||||
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
|
||||
}
|
||||
|
||||
private fun generateInboundEnvelopes(bobClient: BobClient, count: Int): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0..count) {
|
||||
envelopes += bobClient.encrypt(now)
|
||||
now += 3
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
private fun webSocketTombstone(): ByteString {
|
||||
return WebSocketMessage(request = WebSocketRequestMessage(verb = "PUT", path = "/api/v1/queue/empty")).encodeByteString()
|
||||
}
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): ByteString {
|
||||
return WebSocketMessage(
|
||||
type = WebSocketMessage.Type.REQUEST,
|
||||
request = WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random(System.currentTimeMillis()).nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
).encodeByteString()
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.testing.LogPredicate
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
|
||||
companion object {
|
||||
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
|
||||
|
||||
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
|
||||
entry.tag == TAG && entry.message == endTag(timestamp)
|
||||
}
|
||||
|
||||
private fun startTag(timestamp: Long) = "$timestamp start"
|
||||
fun endTag(timestamp: Long) = "$timestamp end"
|
||||
}
|
||||
|
||||
override fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
|
||||
Log.d(TAG, startTag(envelope.timestamp!!))
|
||||
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric)
|
||||
Log.d(TAG, endTag(envelope.timestamp!!))
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
/**
|
||||
* Welcome to Alice's Client.
|
||||
*
|
||||
* Alice represent the Android instrumentation test user. Unlike [BobClient] much less is needed here
|
||||
* as it can make use of the standard Signal Android App infrastructure.
|
||||
*/
|
||||
class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECKeyPair) {
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AliceClient::class.java)
|
||||
}
|
||||
|
||||
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
|
||||
trustRoot = trustRoot,
|
||||
uuid = serviceId.rawUuid,
|
||||
e164 = e164,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
|
||||
expires = 31337
|
||||
)
|
||||
|
||||
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val start = System.currentTimeMillis()
|
||||
val bufferedStore = BufferedProtocolStore.create()
|
||||
AppDependencies.incomingMessageObserver
|
||||
.processEnvelope(bufferedStore, envelope, serverDeliveredTimestamp)
|
||||
?.mapNotNull { it.run() }
|
||||
?.forEach { it.enqueue() }
|
||||
|
||||
bufferedStore.flushToDisk()
|
||||
val end = System.currentTimeMillis()
|
||||
Log.d(TAG, "${end - start}")
|
||||
}
|
||||
|
||||
fun encrypt(now: Long, destination: Recipient): Envelope {
|
||||
return AppDependencies.signalServiceMessageSender.getEncryptedMessage(
|
||||
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
|
||||
FakeClientHelpers.getSealedSenderAccess(ProfileKey(destination.profileKey), aliceSenderCertificate),
|
||||
1,
|
||||
FakeClientHelpers.encryptedTextMessage(now),
|
||||
false
|
||||
).toEnvelope(now, destination.requireServiceId())
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.select
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SessionBuilder
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore.IdentityChange
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.database.KyberPreKeyTable
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
|
||||
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
|
||||
/**
|
||||
* Welcome to Bob's Client.
|
||||
*
|
||||
* Bob is a "fake" client that can start a session with the Android instrumentation test user (Alice).
|
||||
*
|
||||
* Bob can create a new session using a prekey bundle created from Alice's prekeys, send a message, decrypt
|
||||
* a return message from Alice, and that'll start a standard Signal session with normal keys/ratcheting.
|
||||
*/
|
||||
class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val trustRoot: ECKeyPair, val profileKey: ProfileKey) {
|
||||
|
||||
private val serviceAddress = SignalServiceAddress(serviceId, e164)
|
||||
private val registrationId = KeyHelper.generateRegistrationId(false)
|
||||
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
|
||||
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, 31337)
|
||||
private val sessionLock = object : SignalSessionLock {
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
override fun acquire(): SignalSessionLock.Lock {
|
||||
lock.lock()
|
||||
return SignalSessionLock.Lock { lock.unlock() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
|
||||
fun encrypt(now: Long): Envelope {
|
||||
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
|
||||
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
|
||||
sessionBuilder.process(getAlicePreKeyBundle())
|
||||
}
|
||||
|
||||
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
|
||||
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
|
||||
}
|
||||
|
||||
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp)
|
||||
}
|
||||
|
||||
private fun getAliceServiceId(): ServiceId {
|
||||
return SignalStore.account.requireAci()
|
||||
}
|
||||
|
||||
private fun getAlicePreKeyBundle(): PreKeyBundle {
|
||||
val selfPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(OneTimePreKeyTable.KEY_ID)
|
||||
.from(OneTimePreKeyTable.TABLE_NAME)
|
||||
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
val selfPreKeyRecord = SignalDatabase.oneTimePreKeys.get(getAliceServiceId(), selfPreKeyId)!!
|
||||
|
||||
val selfSignedPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(SignedPreKeyTable.KEY_ID)
|
||||
.from(SignedPreKeyTable.TABLE_NAME)
|
||||
.where("${SignedPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
|
||||
|
||||
val selfSignedKyberPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(KyberPreKeyTable.KEY_ID)
|
||||
.from(KyberPreKeyTable.TABLE_NAME)
|
||||
.where("${KyberPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
val selfSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.get(getAliceServiceId(), selfSignedKyberPreKeyId)!!.record
|
||||
|
||||
return PreKeyBundle(
|
||||
SignalStore.account.registrationId,
|
||||
1,
|
||||
selfPreKeyId,
|
||||
selfPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedPreKeyId,
|
||||
selfSignedPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedPreKeyRecord.signature,
|
||||
getAlicePublicKey(),
|
||||
selfSignedKyberPreKeyId,
|
||||
selfSignedKyberPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedKyberPreKeyRecord.signature
|
||||
)
|
||||
}
|
||||
|
||||
private fun getAliceProtocolAddress(): SignalProtocolAddress {
|
||||
return SignalProtocolAddress(SignalStore.account.requireAci().toString(), 1)
|
||||
}
|
||||
|
||||
private fun getAlicePublicKey(): IdentityKey {
|
||||
return SignalStore.account.aciIdentityKey.publicKey
|
||||
}
|
||||
|
||||
private fun getAliceProfileKey(): ProfileKey {
|
||||
return ProfileKeyUtil.getSelfProfileKey()
|
||||
}
|
||||
|
||||
private fun getAliceUnidentifiedAccess(): SealedSenderAccess? {
|
||||
return FakeClientHelpers.getSealedSenderAccess(getAliceProfileKey(), senderCertificate)
|
||||
}
|
||||
|
||||
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
|
||||
private var aliceSessionRecord: SessionRecord? = null
|
||||
|
||||
override fun getIdentityKeyPair(): IdentityKeyPair = identityKeyPair
|
||||
|
||||
override fun getLocalRegistrationId(): Int = registrationId
|
||||
override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true
|
||||
override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord()
|
||||
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityKeyStore.IdentityChange = IdentityChange.NEW_OR_UNCHANGED
|
||||
override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) {
|
||||
aliceSessionRecord = record
|
||||
}
|
||||
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
|
||||
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
|
||||
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account.aciIdentityKey.publicKey
|
||||
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removePreKey(preKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun loadExistingSessions(addresses: MutableList<SignalProtocolAddress>?): MutableList<SessionRecord> = throw UnsupportedOperationException()
|
||||
override fun deleteSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
override fun deleteAllSessions(name: String?) = throw UnsupportedOperationException()
|
||||
override fun loadSignedPreKey(signedPreKeyId: Int): SignedPreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun loadSignedPreKeys(): MutableList<SignedPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKeys(): MutableList<KyberPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun markKyberPreKeyUsed(kyberPreKeyId: Int, signedPreKeyId: Int, baseKey: ECPublicKey) = throw UnsupportedOperationException()
|
||||
override fun deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
|
||||
override fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
|
||||
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
|
||||
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableMap<SignalProtocolAddress, SessionRecord> = throw UnsupportedOperationException()
|
||||
override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
|
||||
override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord) = throw UnsupportedOperationException()
|
||||
override fun removeKyberPreKey(kyberPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
|
||||
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
|
||||
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.metadata.certificate.CertificateValidator
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate
|
||||
import org.signal.libsignal.metadata.certificate.ServerCertificate
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
object FakeClientHelpers {
|
||||
|
||||
val noOpCertificateValidator = object : CertificateValidator(ECKeyPair.generate().publicKey) {
|
||||
override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
|
||||
}
|
||||
|
||||
fun createCertificateFor(trustRoot: ECKeyPair, uuid: UUID, e164: String, deviceId: Int, identityKey: ECPublicKey, expires: Long): SenderCertificate {
|
||||
val serverKey: ECKeyPair = ECKeyPair.generate()
|
||||
val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey)
|
||||
return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.of(e164), deviceId, identityKey, expires)
|
||||
}
|
||||
|
||||
fun getSealedSenderAccess(theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): SealedSenderAccess? {
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
|
||||
|
||||
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
|
||||
}
|
||||
|
||||
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
|
||||
val content = Content.Builder().apply {
|
||||
dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
body = message
|
||||
timestamp = now
|
||||
}
|
||||
)
|
||||
}
|
||||
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
|
||||
}
|
||||
|
||||
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.destinationServiceIdBinary(destination.toByteString())
|
||||
.serverGuid(serverGuid.toString())
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.content(Base64.decode(this.content).toByteString())
|
||||
.urgent(true)
|
||||
.story(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,13 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.benchmark.setup.Generator
|
||||
import org.signal.benchmark.setup.Harness
|
||||
import org.signal.benchmark.setup.OtherClient
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.TestDbUtils
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
@@ -45,6 +50,8 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
when (command) {
|
||||
"individual-send" -> handlePrepareIndividualSend()
|
||||
"group-send" -> handlePrepareGroupSend()
|
||||
"group-delivery-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundDeliveryReceipts(timestamps) }
|
||||
"group-read-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundReadReceipts(timestamps) }
|
||||
"release-messages" -> {
|
||||
BenchmarkWebSocketConnection.authInstance.startWholeBatchTrace = true
|
||||
BenchmarkWebSocketConnection.authInstance.releaseMessages()
|
||||
@@ -113,6 +120,62 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun handlePrepareGroupReceipts(generateReceipts: (OtherClient, List<Long>) -> List<Envelope>) {
|
||||
val clients = Harness.otherClients.take(5)
|
||||
|
||||
establishGroupSessions(clients)
|
||||
|
||||
val timestamps = getOutgoingGroupMessageTimestamps()
|
||||
Log.i(TAG, "Found ${timestamps.size} outgoing message timestamps for receipts")
|
||||
|
||||
val allClientEnvelopes = clients.map { client ->
|
||||
generateReceipts(client, timestamps).map { it.toWebSocketPayload() }
|
||||
}
|
||||
|
||||
BenchmarkWebSocketConnection.authInstance.addPendingMessages(interleave(allClientEnvelopes))
|
||||
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun establishGroupSessions(clients: List<OtherClient>) {
|
||||
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
BenchmarkWebSocketConnection.authInstance.run {
|
||||
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
|
||||
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
releaseMessages()
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOutgoingGroupMessageTimestamps(): List<Long> {
|
||||
val groupId = GroupId.v2(Harness.groupMasterKey)
|
||||
val groupRecipient = Recipient.externalGroupExact(groupId)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
val selfId = Recipient.self().id.toLong()
|
||||
return TestDbUtils.getOutgoingMessageTimestamps(threadId, selfId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interleaves lists so that items from different lists alternate:
|
||||
* [[a1, a2], [b1, b2], [c1, c2]] -> [a1, b1, c1, a2, b2, c2]
|
||||
*/
|
||||
private fun <T> interleave(lists: List<List<T>>): List<T> {
|
||||
val result = mutableListOf<T>()
|
||||
val maxSize = lists.maxOf { it.size }
|
||||
for (i in 0 until maxSize) {
|
||||
for (list in lists) {
|
||||
if (i < list.size) {
|
||||
result += list[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage {
|
||||
return WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
|
||||
@@ -6,7 +6,10 @@ import org.signal.benchmark.setup.TestMessages
|
||||
import org.signal.benchmark.setup.TestUsers
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.TestDbUtils
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
class BenchmarkSetupActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -17,6 +20,8 @@ class BenchmarkSetupActivity : BaseActivity() {
|
||||
"conversation-open" -> setupConversationOpen()
|
||||
"message-send" -> setupMessageSend()
|
||||
"group-message-send" -> setupGroupMessageSend()
|
||||
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
|
||||
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
|
||||
}
|
||||
|
||||
val textView: TextView = TextView(this).apply {
|
||||
@@ -68,4 +73,40 @@ class BenchmarkSetupActivity : BaseActivity() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupGroup()
|
||||
}
|
||||
|
||||
private fun setupGroupReceipt(includeMsl: Boolean = false, enableReadReceipts: Boolean = false) {
|
||||
TestUsers.setupSelf()
|
||||
val groupId = TestUsers.setupGroup()
|
||||
|
||||
val groupRecipient = Recipient.externalGroupExact(groupId)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val messageIds = mutableListOf<Long>()
|
||||
val timestamps = mutableListOf<Long>()
|
||||
val baseTimestamp = 2_000_000L
|
||||
|
||||
for (i in 0 until 100) {
|
||||
val timestamp = baseTimestamp + i
|
||||
val message = OutgoingMessage(
|
||||
recipient = groupRecipient,
|
||||
body = "Outgoing message $i",
|
||||
timestamp = timestamp,
|
||||
isSecure = true
|
||||
)
|
||||
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
|
||||
SignalDatabase.messages.markAsSent(insert.messageId, true)
|
||||
messageIds += insert.messageId
|
||||
timestamps += timestamp
|
||||
}
|
||||
|
||||
if (includeMsl) {
|
||||
val selfId = Recipient.self().id
|
||||
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
|
||||
TestDbUtils.insertMessageSendLogEntries(messageIds, timestamps, memberRecipientIds)
|
||||
}
|
||||
|
||||
if (enableReadReceipts) {
|
||||
TextSecurePreferences.setReadReceiptsEnabled(this, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import org.whispersystems.signalservice.internal.push.ReceiptMessage
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@@ -45,6 +46,26 @@ object Generator {
|
||||
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
|
||||
}
|
||||
|
||||
fun encryptedDeliveryReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
|
||||
return encryptedReceipt(ReceiptMessage.Type.DELIVERY, timestamps)
|
||||
}
|
||||
|
||||
fun encryptedReadReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
|
||||
return encryptedReceipt(ReceiptMessage.Type.READ, timestamps)
|
||||
}
|
||||
|
||||
private fun encryptedReceipt(type: ReceiptMessage.Type, timestamps: List<Long>): EnvelopeContent {
|
||||
val content = Content.Builder().apply {
|
||||
receiptMessage(
|
||||
ReceiptMessage.Builder().buildWith {
|
||||
this.type = type
|
||||
timestamp = timestamps
|
||||
}
|
||||
)
|
||||
}
|
||||
return EnvelopeContent.encrypted(content.build(), ContentHint.IMPLICIT, Optional.empty())
|
||||
}
|
||||
|
||||
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
|
||||
@@ -62,6 +62,10 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
|
||||
|
||||
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
|
||||
fun encrypt(envelopeContent: EnvelopeContent): Envelope {
|
||||
return encrypt(envelopeContent, envelopeContent.content.get().dataMessage!!.timestamp!!)
|
||||
}
|
||||
|
||||
fun encrypt(envelopeContent: EnvelopeContent, timestamp: Long): Envelope {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
@@ -70,7 +74,7 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
|
||||
}
|
||||
|
||||
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
|
||||
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
|
||||
.toEnvelope(timestamp, getAliceServiceId())
|
||||
}
|
||||
|
||||
fun generateInboundEnvelopes(count: Int): List<Envelope> {
|
||||
@@ -84,6 +88,24 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
|
||||
return envelopes
|
||||
}
|
||||
|
||||
fun generateInboundDeliveryReceipts(messageTimestamps: List<Long>): List<Envelope> {
|
||||
return generateInboundReceipts(messageTimestamps, Generator::encryptedDeliveryReceipt)
|
||||
}
|
||||
|
||||
fun generateInboundReadReceipts(messageTimestamps: List<Long>): List<Envelope> {
|
||||
return generateInboundReceipts(messageTimestamps, Generator::encryptedReadReceipt)
|
||||
}
|
||||
|
||||
private fun generateInboundReceipts(messageTimestamps: List<Long>, receiptFactory: (Long, List<Long>) -> EnvelopeContent): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(messageTimestamps.size)
|
||||
var now = System.currentTimeMillis()
|
||||
for (messageTimestamp in messageTimestamps) {
|
||||
envelopes += encrypt(receiptFactory(now, listOf(messageTimestamp)), now)
|
||||
now += 3
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
fun generateInboundGroupEnvelopes(count: Int, groupMasterKey: GroupMasterKey): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.Skipped
|
||||
@@ -170,7 +171,7 @@ object TestUsers {
|
||||
return others
|
||||
}
|
||||
|
||||
fun setupGroup() {
|
||||
fun setupGroup(): GroupId.V2 {
|
||||
val members = setupTestClients(5)
|
||||
val self = Recipient.self()
|
||||
|
||||
@@ -202,6 +203,8 @@ object TestUsers {
|
||||
)
|
||||
|
||||
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId!!).id, true)
|
||||
|
||||
return groupId
|
||||
}
|
||||
|
||||
private fun member(aci: ACI, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0, labelEmoji: String = "", labelString: String = ""): DecryptedMember {
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import org.signal.core.util.SqlUtil.buildArgs
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
|
||||
object TestDbUtils {
|
||||
|
||||
@@ -11,4 +13,58 @@ object TestDbUtils {
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
|
||||
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
|
||||
}
|
||||
|
||||
fun getOutgoingMessageTimestamps(threadId: Long, selfRecipientId: Long): List<Long> {
|
||||
val timestamps = mutableListOf<Long>()
|
||||
SignalDatabase.messages.databaseHelper.signalReadableDatabase.query(
|
||||
MessageTable.TABLE_NAME,
|
||||
arrayOf(MessageTable.DATE_SENT),
|
||||
"${MessageTable.THREAD_ID} = ? AND ${MessageTable.FROM_RECIPIENT_ID} = ?",
|
||||
arrayOf(threadId.toString(), selfRecipientId.toString()),
|
||||
null,
|
||||
null,
|
||||
"${MessageTable.DATE_SENT} ASC"
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
timestamps += cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
return timestamps
|
||||
}
|
||||
|
||||
fun insertMessageSendLogEntries(messageIds: List<Long>, timestamps: List<Long>, recipientIds: List<RecipientId>) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
val dummyContent = Content.Builder().build().encode()
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (i in messageIds.indices) {
|
||||
val payloadValues = ContentValues().apply {
|
||||
put("date_sent", timestamps[i])
|
||||
put("content", dummyContent)
|
||||
put("content_hint", 0)
|
||||
put("urgent", 1)
|
||||
}
|
||||
val payloadId = db.insert("msl_payload", null, payloadValues)
|
||||
|
||||
val messageValues = ContentValues().apply {
|
||||
put("payload_id", payloadId)
|
||||
put("message_id", messageIds[i])
|
||||
}
|
||||
db.insert("msl_message", null, messageValues)
|
||||
|
||||
for (recipientId in recipientIds) {
|
||||
val recipientValues = ContentValues().apply {
|
||||
put("payload_id", payloadId)
|
||||
put("recipient_id", recipientId.toLong())
|
||||
put("device", 1)
|
||||
}
|
||||
db.insert("msl_recipient", null, recipientValues)
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ class ConversationElementGenerator {
|
||||
false,
|
||||
emptyList(),
|
||||
false,
|
||||
false,
|
||||
now,
|
||||
true,
|
||||
now,
|
||||
@@ -122,6 +121,7 @@ class ConversationElementGenerator {
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapterV2
|
||||
@@ -67,7 +67,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
requestManager = Glide.with(this),
|
||||
clickListener = ClickListener(),
|
||||
hasWallpaper = springboardViewModel.hasWallpaper.value,
|
||||
colorizer = Colorizer(),
|
||||
colorizer = ColorizerV2(),
|
||||
startExpirationTimeout = {},
|
||||
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) },
|
||||
displayDialogFragment = {}
|
||||
|
||||
@@ -752,7 +752,7 @@
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".registration.ui.restore.local.InternalNewLocalRestoreActivity"
|
||||
android:name=".registration.ui.restore.local.RestoreLocalBackupActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
@@ -933,6 +933,11 @@
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".groups.memberlabel.MemberLabelActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<!-- ======================================= -->
|
||||
<!-- Activity Aliases -->
|
||||
<!-- ======================================= -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,6 @@ import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -140,6 +139,7 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
|
||||
import org.thoughtcrime.securesms.main.MainMegaphoneState
|
||||
import org.thoughtcrime.securesms.main.MainNavigationBar
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRail
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
@@ -156,7 +156,6 @@ import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
@@ -447,7 +446,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
@@ -477,25 +476,33 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
storiesNavGraphBuilder()
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
when (mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
if (location is MainNavigationDetailLocation.Chats.Conversation) {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
}
|
||||
chatsNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) }
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
|
||||
mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -752,27 +759,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
val coroutine = rememberCoroutineScope()
|
||||
|
||||
return remember(scaffoldNavigator, coroutine) {
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
|
||||
when (detailLocation) {
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> {
|
||||
startActivity(
|
||||
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
|
||||
.withArgs(detailLocation.conversationArgs)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
|
||||
error("Unexpected subroute EditCallLinkName.")
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> Unit
|
||||
}
|
||||
}
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class MuteDialog extends AlertDialog {
|
||||
|
||||
|
||||
protected MuteDialog(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
|
||||
super(context, cancelable, cancelListener);
|
||||
}
|
||||
|
||||
protected MuteDialog(Context context, int theme) {
|
||||
super(context, theme);
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
|
||||
show(context, listener, null);
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
|
||||
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
||||
builder.setItems(R.array.mute_durations, (dialog, which) -> {
|
||||
final long muteUntil;
|
||||
|
||||
switch (which) {
|
||||
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
||||
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
|
||||
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
|
||||
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
|
||||
case 4: muteUntil = Long.MAX_VALUE; break;
|
||||
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
||||
}
|
||||
|
||||
listener.onMuted(muteUntil);
|
||||
});
|
||||
|
||||
if (cancelListener != null) {
|
||||
builder.setOnCancelListener(dialog -> {
|
||||
cancelListener.run();
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
builder.show();
|
||||
|
||||
}
|
||||
|
||||
public interface MuteSelectionListener {
|
||||
public void onMuted(long until);
|
||||
}
|
||||
|
||||
}
|
||||
73
app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt
Normal file
73
app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt
Normal file
@@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.MuteUntilTimePickerBottomSheet
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
object MuteDialog {
|
||||
|
||||
private const val MUTE_UNTIL: Long = -1L
|
||||
|
||||
private data class MuteOption(
|
||||
@DrawableRes val iconRes: Int,
|
||||
val title: String,
|
||||
val duration: Long
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun show(context: Context, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: MuteSelectionListener) {
|
||||
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
|
||||
action.onMuted(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
|
||||
}
|
||||
|
||||
val options = listOf(
|
||||
MuteOption(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour), 1.hours.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours), 8.hours.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day), 1.days.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days), 7.days.inWholeMilliseconds),
|
||||
MuteOption(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until), MUTE_UNTIL),
|
||||
MuteOption(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always), Long.MAX_VALUE)
|
||||
)
|
||||
|
||||
val adapter = object : BaseAdapter() {
|
||||
override fun getCount(): Int = options.size
|
||||
override fun getItem(position: Int): MuteOption = options[position]
|
||||
override fun getItemId(position: Int): Long = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.mute_dialog_item, parent, false)
|
||||
val option = options[position]
|
||||
view.findViewById<ImageView>(R.id.mute_dialog_icon).setImageResource(option.iconRes)
|
||||
view.findViewById<TextView>(R.id.mute_dialog_title).text = option.title
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.MuteDialog_mute_notifications)
|
||||
.setAdapter(adapter) { _, which ->
|
||||
val option = options[which]
|
||||
when (option.duration) {
|
||||
MUTE_UNTIL -> MuteUntilTimePickerBottomSheet.show(fragmentManager)
|
||||
Long.MAX_VALUE -> action.onMuted(Long.MAX_VALUE)
|
||||
else -> action.onMuted(System.currentTimeMillis() + option.duration)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
fun interface MuteSelectionListener {
|
||||
fun onMuted(until: Long)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ abstract class Attachment(
|
||||
* Denotes whether the media for the given attachment is no longer available for download.
|
||||
*/
|
||||
val isMediaNoLongerAvailableForDownload: Boolean
|
||||
get() = isPermanentlyFailed && uploadTimestamp.milliseconds > 30.days
|
||||
get() = isPermanentlyFailed && (System.currentTimeMillis().milliseconds - uploadTimestamp.milliseconds) > 30.days
|
||||
|
||||
val isSticker: Boolean
|
||||
get() = stickerLocator != null
|
||||
|
||||
@@ -214,6 +214,10 @@ object ExportOddities {
|
||||
return log(0, "Distribution list had self as a member. Removing it.")
|
||||
}
|
||||
|
||||
fun quoteAuthorNotFound(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote author was not found in the exported recipients. Removing the quote.")
|
||||
}
|
||||
|
||||
fun emptyQuote(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
|
||||
}
|
||||
@@ -283,6 +287,10 @@ object ImportSkips {
|
||||
return log(0, "Missing recipient for chat $chatId")
|
||||
}
|
||||
|
||||
fun missingAdminDeleteRecipient(sentTimestamp: Long, chatId: Long): String {
|
||||
return log(sentTimestamp, "Missing admin delete recipient for chat $chatId")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[SKIP][$sentTimestamp] $message"
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
${MessageTable.FROM_RECIPIENT_ID},
|
||||
${MessageTable.TO_RECIPIENT_ID},
|
||||
${MessageTable.EXPIRE_STARTED},
|
||||
${MessageTable.REMOTE_DELETED},
|
||||
${MessageTable.UNIDENTIFIED},
|
||||
${MessageTable.LINK_PREVIEWS},
|
||||
${MessageTable.SHARED_CONTACTS},
|
||||
@@ -68,7 +67,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
${MessageTable.VIEW_ONCE},
|
||||
${MessageTable.PINNED_UNTIL},
|
||||
${MessageTable.PINNING_MESSAGE_ID},
|
||||
${MessageTable.PINNED_AT}
|
||||
${MessageTable.PINNED_AT},
|
||||
${MessageTable.DELETED_BY}
|
||||
)
|
||||
WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1
|
||||
""".trimMargin()
|
||||
@@ -136,7 +136,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
MessageTable.TO_RECIPIENT_ID,
|
||||
EXPIRES_IN,
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
MessageTable.LINK_PREVIEWS,
|
||||
MessageTable.SHARED_CONTACTS,
|
||||
@@ -161,7 +160,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
|
||||
PARENT_STORY_ID,
|
||||
MessageTable.PINNED_UNTIL,
|
||||
MessageTable.PINNING_MESSAGE_ID,
|
||||
MessageTable.PINNED_AT
|
||||
MessageTable.PINNED_AT,
|
||||
MessageTable.DELETED_BY
|
||||
)
|
||||
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
|
||||
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.emptyIfNull
|
||||
import org.signal.core.util.isEmpty
|
||||
import org.signal.core.util.isNotEmpty
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.kibiBytes
|
||||
@@ -42,6 +41,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupMode
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportOddities
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AdminDeletedMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
|
||||
@@ -193,11 +193,16 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
|
||||
when {
|
||||
record.remoteDeleted -> {
|
||||
record.deletedBy == record.fromRecipientId -> {
|
||||
builder.remoteDeletedMessage = RemoteDeletedMessage()
|
||||
transformTimer.emit("remote-delete")
|
||||
}
|
||||
|
||||
record.deletedBy != null -> {
|
||||
builder.adminDeletedMessage = AdminDeletedMessage(adminId = record.deletedBy)
|
||||
transformTimer.emit("admin-delete")
|
||||
}
|
||||
|
||||
MessageTypes.isJoinedType(record.type) -> {
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
|
||||
transformTimer.emit("simple-update")
|
||||
@@ -564,7 +569,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
|
||||
}
|
||||
|
||||
val direction = when {
|
||||
record.type.isDirectionlessType() && !record.remoteDeleted -> {
|
||||
record.type.isDirectionlessType() && record.deletedBy == null -> {
|
||||
Direction.DIRECTIONLESS
|
||||
}
|
||||
MessageTypes.isOutgoingMessageType(record.type) || record.fromRecipientId == selfRecipientId.toLong() -> {
|
||||
@@ -1169,6 +1174,11 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
|
||||
return null
|
||||
}
|
||||
|
||||
if (!exportState.recipientIds.contains(this.quoteAuthor)) {
|
||||
Log.w(TAG, ExportOddities.quoteAuthorNotFound(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
val localType = QuoteModel.Type.fromCode(this.quoteType)
|
||||
val remoteType = when (localType) {
|
||||
QuoteModel.Type.NORMAL -> {
|
||||
@@ -1360,11 +1370,12 @@ private fun FailureReason?.toRemote(): PaymentNotification.TransactionDetails.Fa
|
||||
}
|
||||
|
||||
private fun List<Mention>.toRemoteBodyRanges(exportState: ExportState): List<BackupBodyRange> {
|
||||
return this.map {
|
||||
return this.mapNotNull {
|
||||
val aci = exportState.recipientIdToAci[it.recipientId.toLong()] ?: return@mapNotNull null
|
||||
BackupBodyRange(
|
||||
start = it.start,
|
||||
length = it.length,
|
||||
mentionAci = exportState.recipientIdToAci[it.recipientId.toLong()]
|
||||
mentionAci = aci
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1662,7 +1673,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
|
||||
this.giftBadge == null &&
|
||||
this.viewOnceMessage == null &&
|
||||
this.directStoryReplyMessage == null &&
|
||||
this.poll == null
|
||||
this.poll == null &&
|
||||
this.adminDeletedMessage == null
|
||||
) {
|
||||
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
|
||||
return null
|
||||
@@ -1805,7 +1817,6 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
|
||||
toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID),
|
||||
expiresIn = expiresIn,
|
||||
expireStarted = expireStarted,
|
||||
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
|
||||
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
|
||||
linkPreview = this.requireString(MessageTable.LINK_PREVIEWS),
|
||||
sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS),
|
||||
@@ -1830,6 +1841,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
|
||||
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
|
||||
pinnedAt = this.requireLong(MessageTable.PINNED_AT),
|
||||
pinnedUntil = this.requireLong(MessageTable.PINNED_UNTIL),
|
||||
deletedBy = this.requireLongOrNull(MessageTable.DELETED_BY),
|
||||
messageExtrasSize = messageExtras?.size ?: 0
|
||||
)
|
||||
}
|
||||
@@ -1847,7 +1859,6 @@ private class BackupMessageRecord(
|
||||
val toRecipientId: Long,
|
||||
val expiresIn: Long,
|
||||
val expireStarted: Long,
|
||||
val remoteDeleted: Boolean,
|
||||
val sealedSender: Boolean,
|
||||
val linkPreview: String?,
|
||||
val sharedContacts: String?,
|
||||
@@ -1872,6 +1883,7 @@ private class BackupMessageRecord(
|
||||
val viewOnce: Boolean,
|
||||
val pinnedAt: Long,
|
||||
val pinnedUntil: Long,
|
||||
val deletedBy: Long?,
|
||||
private val messageExtrasSize: Int
|
||||
) {
|
||||
val estimatedSizeInBytes: Int = (body?.length ?: 0) +
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.backup.v2.util.clampToValidBackupRange
|
||||
import org.thoughtcrime.securesms.backup.v2.util.isValidUsername
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toRemote
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
|
||||
@@ -75,7 +76,7 @@ class ContactArchiveExporter(private val cursor: Cursor, private val selfId: Lon
|
||||
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
|
||||
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
|
||||
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
|
||||
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { Base64.decode(it) }?.toByteString())
|
||||
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { ProfileKeyUtil.profileKeyOrNull(it)?.serialize()?.toByteString() })
|
||||
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
|
||||
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME))
|
||||
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME))
|
||||
|
||||
@@ -116,7 +116,7 @@ private fun AccessControl.toRemote(): Group.AccessControl {
|
||||
|
||||
private fun Member.Role.toRemote(): Group.Member.Role {
|
||||
return when (this) {
|
||||
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
|
||||
Member.Role.UNKNOWN -> Group.Member.Role.DEFAULT
|
||||
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
|
||||
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ class ChatItemArchiveImporter(
|
||||
MessageTable.EXPIRES_IN,
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.NETWORK_FAILURES,
|
||||
MessageTable.QUOTE_ID,
|
||||
MessageTable.QUOTE_AUTHOR,
|
||||
@@ -141,7 +140,8 @@ class ChatItemArchiveImporter(
|
||||
MessageTable.NOTIFIED,
|
||||
MessageTable.PINNED_UNTIL,
|
||||
MessageTable.PINNING_MESSAGE_ID,
|
||||
MessageTable.PINNED_AT
|
||||
MessageTable.PINNED_AT,
|
||||
MessageTable.DELETED_BY
|
||||
)
|
||||
|
||||
private val REACTION_COLUMNS = arrayOf(
|
||||
@@ -193,6 +193,12 @@ class ChatItemArchiveImporter(
|
||||
Log.w(TAG, ImportSkips.chatIdRemoteRecipientNotFound(chatItem.dateSent, chatItem.chatId))
|
||||
return
|
||||
}
|
||||
|
||||
if (chatItem.adminDeletedMessage != null && importState.remoteToLocalRecipientId[chatItem.adminDeletedMessage.adminId] == null) {
|
||||
Log.w(TAG, ImportSkips.missingAdminDeleteRecipient(chatItem.dateSent, chatItem.chatId))
|
||||
return
|
||||
}
|
||||
|
||||
val messageInsert = chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
|
||||
if (chatItem.revisions.isNotEmpty()) {
|
||||
// Flush to avoid having revisions cross batch boundaries, which will cause a foreign key failure
|
||||
@@ -672,7 +678,6 @@ class ChatItemArchiveImporter(
|
||||
contentValues.put(MessageTable.QUOTE_MISSING, 0)
|
||||
contentValues.put(MessageTable.QUOTE_TYPE, 0)
|
||||
contentValues.put(MessageTable.VIEW_ONCE, 0)
|
||||
contentValues.put(MessageTable.REMOTE_DELETED, 0)
|
||||
contentValues.put(MessageTable.PARENT_STORY_ID, 0)
|
||||
|
||||
if (this.pinDetails != null) {
|
||||
@@ -683,12 +688,13 @@ class ChatItemArchiveImporter(
|
||||
|
||||
when {
|
||||
this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage)
|
||||
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
|
||||
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
|
||||
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage, fromRecipientId, toRecipientId)
|
||||
this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
|
||||
this.giftBadge != null -> contentValues.addGiftBadge(this.giftBadge)
|
||||
this.viewOnceMessage != null -> contentValues.addViewOnce(this.viewOnceMessage)
|
||||
this.directStoryReplyMessage != null -> contentValues.addDirectStoryReply(this.directStoryReplyMessage, toRecipientId)
|
||||
this.adminDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, importState.remoteToLocalRecipientId[this.adminDeletedMessage.adminId]!!.toLong())
|
||||
}
|
||||
|
||||
return contentValues
|
||||
|
||||
@@ -219,9 +219,16 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
|
||||
private fun handleDeleteSelectedRows() {
|
||||
val count = callLogActionMode.getCount()
|
||||
val selectionState = viewModel.selectionStateSnapshot
|
||||
val hasCallLinks = selectionState.isExclusionary() || selectionState.selected().any { it is CallLogRow.Id.CallLink }
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
|
||||
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
.apply {
|
||||
if (hasCallLinks) {
|
||||
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
}
|
||||
}
|
||||
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
|
||||
performDeletion(count, viewModel.stageSelectionDeletion())
|
||||
callLogActionMode.end()
|
||||
@@ -380,7 +387,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
override fun deleteCall(call: CallLogRow) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
.apply {
|
||||
if (call is CallLogRow.CallLink) {
|
||||
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
|
||||
}
|
||||
}
|
||||
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
|
||||
performDeletion(1, viewModel.stageCallDeletion(call))
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ private fun NewCallScreen(
|
||||
val context = LocalActivity.current as FragmentActivity
|
||||
|
||||
val callbacks = remember {
|
||||
object : UiCallbacks {
|
||||
object : NewCallUiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
|
||||
override fun onRecipientSelected(selection: RecipientSelection) = viewModel.startCall(selection)
|
||||
override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context))
|
||||
@@ -111,7 +111,7 @@ private fun NewCallScreen(
|
||||
)
|
||||
}
|
||||
|
||||
private interface UiCallbacks :
|
||||
private interface NewCallUiCallbacks :
|
||||
RecipientPickerCallbacks.ListActions,
|
||||
RecipientPickerCallbacks.Refresh,
|
||||
RecipientPickerCallbacks.NewCall {
|
||||
@@ -120,7 +120,7 @@ private interface UiCallbacks :
|
||||
fun onUserMessageDismissed(userMessage: UserMessage)
|
||||
fun onBackPressed()
|
||||
|
||||
object Empty : UiCallbacks {
|
||||
object Empty : NewCallUiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = Unit
|
||||
override fun onRecipientSelected(selection: RecipientSelection) = Unit
|
||||
override fun onInviteToSignal() = Unit
|
||||
@@ -134,7 +134,7 @@ private interface UiCallbacks :
|
||||
@Composable
|
||||
private fun NewCallScreenUi(
|
||||
uiState: NewCallUiState,
|
||||
callbacks: UiCallbacks
|
||||
callbacks: NewCallUiCallbacks
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -173,7 +173,7 @@ private fun NewCallScreenUi(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBarActions(callbacks: UiCallbacks) {
|
||||
private fun TopAppBarActions(callbacks: NewCallUiCallbacks) {
|
||||
val menuController = remember { DropdownMenus.MenuController() }
|
||||
IconButton(
|
||||
onClick = { menuController.show() },
|
||||
@@ -250,7 +250,7 @@ private fun NewCallScreenPreview() {
|
||||
uiState = NewCallUiState(
|
||||
forceSplitPane = false
|
||||
),
|
||||
callbacks = UiCallbacks.Empty
|
||||
callbacks = NewCallUiCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ActionMode;
|
||||
import android.view.View;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
@@ -284,6 +285,10 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
|
||||
setImeOptions(getImeOptions() | 16777216);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,10 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
|
||||
|
||||
if (getShowsDialog()) {
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
|
||||
}
|
||||
}
|
||||
|
||||
protected void onNavigateUp() {
|
||||
|
||||
@@ -64,6 +64,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
|
||||
private var insets: WindowInsetsCompat? = null
|
||||
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
|
||||
private var navigationBarInsetOverride: Int? = null
|
||||
|
||||
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
|
||||
this.insets = insets
|
||||
@@ -114,6 +115,23 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setNavigationBarInsetOverride(inset: Int?) {
|
||||
if (navigationBarInsetOverride == inset) return
|
||||
navigationBarInsetOverride = inset
|
||||
if (inset != null) {
|
||||
// Apply immediately so layout is correct before next inset dispatch (important for
|
||||
// Android 15 bubble where insets can arrive late or with different values).
|
||||
navigationBarGuideline?.setGuidelineEnd(inset)
|
||||
if (!isKeyboardShowing) {
|
||||
keyboardGuideline?.setGuidelineEnd(inset)
|
||||
}
|
||||
requestLayout()
|
||||
}
|
||||
if (insets != null) {
|
||||
applyInsets(insets!!.getInsets(windowTypes), insets!!.getInsets(keyboardType))
|
||||
}
|
||||
}
|
||||
|
||||
fun addKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
keyboardStateListeners += listener
|
||||
}
|
||||
@@ -134,7 +152,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
val statusBar = windowInsets.top
|
||||
val navigationBar = if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
|
||||
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
|
||||
ViewUtil.getNavigationBarHeight(resources)
|
||||
} else {
|
||||
windowInsets.bottom
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.signal.core.ui.initializeScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
|
||||
@@ -57,6 +58,11 @@ abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_containe
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dialog?.window?.initializeScreenshotSecurity()
|
||||
}
|
||||
|
||||
open fun onHandleBackPressed() {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
@@ -264,6 +264,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
previousOverflowText = overflowText;
|
||||
useSystemEmoji = useSystemEmoji();
|
||||
previousTransformationMethod = getTransformationMethod();
|
||||
lastSizeChangedWidth = -1;
|
||||
lastSizeChangedHeight = -1;
|
||||
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
// We ellipsize them ourselves by manually truncating the appropriate section.
|
||||
@@ -590,7 +592,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
lastSizeChangedWidth = w;
|
||||
lastSizeChangedHeight = h;
|
||||
|
||||
if (!sizeChangeInProgress) {
|
||||
if (!sizeChangeInProgress && getMaxLines() > 0 && getMaxLines() < Integer.MAX_VALUE) {
|
||||
sizeChangeInProgress = true;
|
||||
resetText();
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -21,10 +24,12 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Applies Signal or System emoji to the given content based off user settings.
|
||||
@@ -34,6 +39,7 @@ import org.signal.core.ui.compose.Previews
|
||||
@Composable
|
||||
fun Emojifier(
|
||||
text: String,
|
||||
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
|
||||
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
|
||||
Text(
|
||||
text = annotatedText,
|
||||
@@ -41,38 +47,56 @@ fun Emojifier(
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
if (useSystemEmoji) {
|
||||
content(buildAnnotatedString { append(text) }, emptyMap())
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val candidates = remember(text) { EmojiProvider.getCandidates(text) }
|
||||
val candidateMap: Map<String, InlineTextContent> = remember(text) {
|
||||
candidates?.associate { candidate ->
|
||||
candidate.drawInfo.emoji to InlineTextContent(placeholder = Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, candidate.drawInfo.emoji)),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
} ?: emptyMap()
|
||||
val fontSize = LocalTextStyle.current.fontSize
|
||||
|
||||
val foundEmojis: List<EmojiParser.Candidate> = remember(text) {
|
||||
EmojiProvider.getCandidates(text)?.list.orEmpty()
|
||||
}
|
||||
val inlineContentByEmoji: Map<String, InlineTextContent> = remember(text, fontSize) {
|
||||
foundEmojis.associate { it.drawInfo.emoji to createInlineContent(context, it.drawInfo.emoji, fontSize) }
|
||||
}
|
||||
|
||||
val annotatedString = buildAnnotatedString {
|
||||
append(text)
|
||||
val annotatedString = remember(text) { buildAnnotatedString(text, foundEmojis) }
|
||||
content(annotatedString, inlineContentByEmoji)
|
||||
}
|
||||
|
||||
candidates?.forEach {
|
||||
addStringAnnotation(
|
||||
tag = "EMOJI",
|
||||
annotation = it.drawInfo.emoji,
|
||||
start = it.startIndex,
|
||||
end = it.endIndex
|
||||
)
|
||||
private fun createInlineContent(context: Context, emoji: String, fontSize: TextUnit): InlineTextContent {
|
||||
return InlineTextContent(
|
||||
placeholder = Placeholder(width = fontSize, height = fontSize, PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Image(
|
||||
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, emoji)),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an [AnnotatedString] from [text], substituting each emoji in [foundEmojis] with an inline content placeholder.
|
||||
*/
|
||||
private fun buildAnnotatedString(
|
||||
text: String,
|
||||
foundEmojis: List<EmojiParser.Candidate>
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
var nextSegmentStartIndex = 0
|
||||
|
||||
foundEmojis.forEach { emoji ->
|
||||
if (emoji.startIndex > nextSegmentStartIndex) {
|
||||
append(text, start = nextSegmentStartIndex, end = emoji.startIndex)
|
||||
}
|
||||
appendInlineContent(emoji.drawInfo.emoji)
|
||||
nextSegmentStartIndex = emoji.endIndex
|
||||
}
|
||||
|
||||
content(annotatedString, candidateMap)
|
||||
if (nextSegmentStartIndex < text.length) {
|
||||
append(text, start = nextSegmentStartIndex, end = text.length)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -41,6 +41,9 @@ public class EmojiParser {
|
||||
this.emojiTree = emojiTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ordered list of every emoji occurrence found in the given text.
|
||||
*/
|
||||
public @NonNull CandidateList findCandidates(@Nullable CharSequence text) {
|
||||
List<Candidate> results = new LinkedList<>();
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -52,7 +52,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
|
||||
AppSettingsRoute.Empty -> null
|
||||
is AppSettingsRoute.BackupsRoute.Local -> {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow))) {
|
||||
AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
|
||||
.setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
|
||||
} else {
|
||||
|
||||
@@ -57,7 +57,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBa
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
@@ -104,13 +104,12 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
},
|
||||
onOnDeviceBackupsRowClick = {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !SignalStore.settings.isBackupEnabled) {
|
||||
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
|
||||
} else {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
|
||||
}
|
||||
},
|
||||
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
|
||||
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
|
||||
)
|
||||
}
|
||||
@@ -122,7 +121,6 @@ private fun BackupsSettingsContent(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onBackupsRowClick: () -> Unit = {},
|
||||
onOnDeviceBackupsRowClick: () -> Unit = {},
|
||||
onNewOnDeviceBackupsRowClick: () -> Unit = {},
|
||||
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
@@ -241,16 +239,6 @@ private fun BackupsSettingsContent(
|
||||
onClick = onOnDeviceBackupsRowClick
|
||||
)
|
||||
}
|
||||
|
||||
if (backupsSettingsState.showNewLocalBackup) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "INTERNAL ONLY - New Local Backup",
|
||||
label = "Use new local backup format",
|
||||
onClick = onNewOnDeviceBackupsRowClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,5 @@ data class BackupsSettingsState(
|
||||
val backupState: BackupState,
|
||||
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
val showBackupTierInternalOverride: Boolean = false,
|
||||
val backupTierInternalOverride: MessageBackupTier? = null,
|
||||
val showNewLocalBackup: Boolean = false
|
||||
val backupTierInternalOverride: MessageBackupTier? = null
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class BackupsSettingsViewModel : ViewModel() {
|
||||
@@ -46,8 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
backupState = enabledState,
|
||||
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
showBackupTierInternalOverride = Environment.IS_STAGING,
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
|
||||
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
|
||||
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.time.LocalTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* App settings internal screen for enabling and creating new local backups.
|
||||
*/
|
||||
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
|
||||
|
||||
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
|
||||
|
||||
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private var createStatus by mutableStateOf("None")
|
||||
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
chooseBackupLocationLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
|
||||
handleBackupLocationSelected(result.data!!.data!!)
|
||||
} else {
|
||||
Log.w(TAG, "Backup location selection cancelled or failed")
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onEvent(event: LocalBackupV2Event) {
|
||||
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val context = LocalContext.current
|
||||
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
|
||||
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
|
||||
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
|
||||
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
|
||||
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
|
||||
|
||||
InternalLocalBackupScreen(
|
||||
backupsEnabled = backupsEnabled,
|
||||
selectedDirectory = selectedDirectory,
|
||||
lastBackupTimeString = lastBackupTimeString,
|
||||
backupTime = backupTime,
|
||||
createStatus = createStatus,
|
||||
callback = CallbackImpl()
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchBackupDirectoryPicker() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
|
||||
if (latestDirectory != null) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Launching backup directory picker")
|
||||
chooseBackupLocationLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to launch backup directory picker", e)
|
||||
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackupLocationSelected(uri: Uri) {
|
||||
Log.i(TAG, "Backup location selected: $uri")
|
||||
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
|
||||
|
||||
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
|
||||
return if (lastBackupTimestamp > 0) {
|
||||
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
context,
|
||||
Locale.getDefault(),
|
||||
lastBackupTimestamp
|
||||
)
|
||||
|
||||
if (relativeTime.isRelative) {
|
||||
relativeTime.value
|
||||
} else {
|
||||
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
|
||||
val time = relativeTime.value
|
||||
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
|
||||
}
|
||||
} else {
|
||||
context.getString(R.string.RemoteBackupsSettingsFragment__never)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CallbackImpl : Callback {
|
||||
override fun onNavigationClick() {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onToggleBackupsClick(enabled: Boolean) {
|
||||
SignalStore.backup.newLocalBackupsEnabled = enabled
|
||||
if (enabled) {
|
||||
LocalBackupListener.schedule(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSelectDirectoryClick() {
|
||||
launchBackupDirectoryPicker()
|
||||
}
|
||||
|
||||
override fun onEnqueueBackupClick() {
|
||||
createStatus = "Starting..."
|
||||
LocalBackupJob.enqueueArchive(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface Callback {
|
||||
fun onNavigationClick()
|
||||
fun onToggleBackupsClick(enabled: Boolean)
|
||||
fun onSelectDirectoryClick()
|
||||
fun onEnqueueBackupClick()
|
||||
|
||||
object Empty : Callback {
|
||||
override fun onNavigationClick() = Unit
|
||||
override fun onToggleBackupsClick(enabled: Boolean) = Unit
|
||||
override fun onSelectDirectoryClick() = Unit
|
||||
override fun onEnqueueBackupClick() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InternalLocalBackupScreen(
|
||||
backupsEnabled: Boolean = false,
|
||||
selectedDirectory: String? = null,
|
||||
lastBackupTimeString: String = "Never",
|
||||
backupTime: String = "Unknown",
|
||||
createStatus: String = "None",
|
||||
callback: Callback
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "New Local Backups",
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
onNavigationClick = callback::onNavigationClick
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = backupsEnabled,
|
||||
text = "Enable New Local Backups",
|
||||
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
|
||||
onCheckChanged = callback::onToggleBackupsClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Last Backup",
|
||||
label = lastBackupTimeString
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Backup Schedule Time (same as v1)",
|
||||
label = backupTime
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Select Backup Directory",
|
||||
label = selectedDirectory ?: "No directory selected",
|
||||
onClick = callback::onSelectDirectoryClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Create Backup Now",
|
||||
label = "Enqueue LocalArchiveJob",
|
||||
onClick = callback::onEnqueueBackupClick
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = "Create Status",
|
||||
label = createStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun InternalLocalBackupScreenPreview() {
|
||||
Previews.Preview {
|
||||
InternalLocalBackupScreen(
|
||||
backupsEnabled = true,
|
||||
selectedDirectory = "/storage/emulated/0/Signal/Backups",
|
||||
lastBackupTimeString = "1 hour ago",
|
||||
callback = Callback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,11 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -31,6 +29,7 @@ import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
|
||||
@@ -158,11 +157,10 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
|
||||
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
|
||||
val context = LocalContext.current
|
||||
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == Activity.RESULT_OK && uri != null) {
|
||||
return Launchers.rememberOpenDocumentTreeLauncher { uri ->
|
||||
if (uri != null) {
|
||||
Log.i(TAG, "Backup location selected: $uri")
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
@@ -6,9 +6,7 @@ package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.net.Uri
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
@@ -52,7 +50,7 @@ sealed interface LocalBackupsSettingsCallback {
|
||||
|
||||
class DefaultLocalBackupsSettingsCallback(
|
||||
private val fragment: LocalBackupsFragment,
|
||||
private val chooseBackupLocationLauncher: ActivityResultLauncher<Intent>,
|
||||
private val chooseBackupLocationLauncher: ActivityResultLauncher<Uri?>,
|
||||
private val viewModel: LocalBackupsViewModel
|
||||
) : LocalBackupsSettingsCallback {
|
||||
|
||||
@@ -65,22 +63,10 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
}
|
||||
|
||||
override fun onLaunchBackupLocationPickerClick() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings.latestSignalBackupDirectory)
|
||||
}
|
||||
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Starting choose backup location dialog")
|
||||
chooseBackupLocationLauncher.launch(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
chooseBackupLocationLauncher.launch(SignalStore.settings.latestSignalBackupDirectory)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.text.NumberFormat
|
||||
@@ -96,7 +95,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
val clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
|
||||
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
|
||||
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
|
||||
|
||||
if (SignalStore.backup.newLocalBackupsEnabled) {
|
||||
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {
|
||||
|
||||
@@ -798,6 +798,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Add remote backups note"),
|
||||
onClick = {
|
||||
viewModel.addSampleReleaseNote("remote_backups")
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Add remote donate megaphone"),
|
||||
onClick = {
|
||||
|
||||
@@ -42,7 +42,7 @@ class InternalSettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
fun addSampleReleaseNote(callToAction: String) {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
AppDependencies.jobManager.runSynchronously(CreateReleaseChannelJob.create(), 5000)
|
||||
|
||||
@@ -52,7 +52,7 @@ class InternalSettingsRepository(context: Context) {
|
||||
val bodyRangeList = BodyRangeList.Builder()
|
||||
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
|
||||
|
||||
bodyRangeList.addButton("Call to Action Text", "action", body.lastIndex, 0)
|
||||
bodyRangeList.addButton("Call to Action Text", callToAction, body.lastIndex, 0)
|
||||
|
||||
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
@@ -154,8 +154,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
repository.addSampleReleaseNote()
|
||||
fun addSampleReleaseNote(callToAction: String = "action") {
|
||||
repository.addSampleReleaseNote(callToAction)
|
||||
}
|
||||
|
||||
fun addRemoteDonateMegaphone() {
|
||||
|
||||
@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.BackupValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
|
||||
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
|
||||
|
||||
class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
|
||||
@@ -230,7 +230,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
.setTitle("Are you sure?")
|
||||
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
|
||||
.setPositiveButton("Wipe and restore") { _, _ ->
|
||||
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
|
||||
startActivity(RestoreLocalBackupActivity.getIntent(context, finish = false))
|
||||
}
|
||||
.show()
|
||||
},
|
||||
|
||||
@@ -30,26 +30,28 @@ class CheckoutNavHostFragment : NavHostFragment() {
|
||||
get() = requireArguments().getSerializableCompat(ARG_TYPE, InAppPaymentType::class.java)!!
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (savedInstanceState == null) {
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
|
||||
navGraph.setStartDestination(
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
|
||||
}
|
||||
)
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
|
||||
navGraph.setStartDestination(
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
|
||||
}
|
||||
)
|
||||
|
||||
val startBundle = when (inAppPaymentType) {
|
||||
val startBundle = if (savedInstanceState == null) {
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unknown payment type")
|
||||
InAppPaymentType.ONE_TIME_GIFT, InAppPaymentType.RECURRING_BACKUP -> null
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> DonateToSignalFragmentArgs.Builder(inAppPaymentType).build().toBundle()
|
||||
}
|
||||
|
||||
navController.setGraph(navGraph, startBundle)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
navController.setGraph(navGraph, startBundle)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ class DonateToSignalFragment :
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
isEnabled = state.areFieldsEnabled,
|
||||
onClick = {
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending && !state.monthlyDonationState.transactionState.isKeepAlive) {
|
||||
showDonationPendingDialog(state)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
|
||||
@@ -139,7 +139,8 @@ data class DonateToSignalState(
|
||||
|
||||
data class TransactionState(
|
||||
val isTransactionJobPending: Boolean = false,
|
||||
val isLevelUpdateInProgress: Boolean = false
|
||||
val isLevelUpdateInProgress: Boolean = false,
|
||||
val isKeepAlive: Boolean = false
|
||||
) {
|
||||
val isInProgress: Boolean = isTransactionJobPending || isLevelUpdateInProgress
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ class DonateToSignalViewModel(
|
||||
state.copy(
|
||||
monthlyDonationState = state.monthlyDonationState.copy(
|
||||
nonVerifiedMonthlyDonation = if (jobStatus is DonationRedemptionJobStatus.PendingExternalVerification) jobStatus.nonVerifiedMonthlyDonation else null,
|
||||
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing)
|
||||
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing, jobStatus is DonationRedemptionJobStatus.PendingKeepAlive)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ object BankDetailsValidator {
|
||||
private val EMAIL_REGEX: Regex = ".+@.+\\..+".toRegex()
|
||||
|
||||
fun validName(name: String): Boolean {
|
||||
return name.length >= 2
|
||||
return name.length >= 3
|
||||
}
|
||||
|
||||
fun validEmail(email: String): Boolean {
|
||||
|
||||
@@ -312,7 +312,7 @@ private fun BankTransferDetailsContent(
|
||||
isError = state.showNameError(),
|
||||
supportingText = {
|
||||
if (state.showNameError()) {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -266,7 +266,7 @@ private fun IdealTransferDetailsContent(
|
||||
isError = state.showNameError(),
|
||||
supportingText = {
|
||||
if (state.showNameError()) {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -34,7 +34,6 @@ object ActiveSubscriptionPreference {
|
||||
val activeSubscription: ActiveSubscription.Subscription?,
|
||||
val subscriberRequiresCancel: Boolean,
|
||||
val onContactSupport: () -> Unit,
|
||||
val onPendingClick: (FiatMoney) -> Unit,
|
||||
val onRowClick: (ManageDonationsState.RedemptionState) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
@@ -79,7 +78,7 @@ object ActiveSubscriptionPreference {
|
||||
|
||||
when (model.redemptionState) {
|
||||
ManageDonationsState.RedemptionState.NONE -> presentRenewalState(model)
|
||||
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState(model)
|
||||
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState()
|
||||
ManageDonationsState.RedemptionState.IN_PROGRESS -> presentInProgressState()
|
||||
ManageDonationsState.RedemptionState.FAILED -> presentFailureState(model)
|
||||
ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH -> presentRefreshState()
|
||||
@@ -102,10 +101,9 @@ object ActiveSubscriptionPreference {
|
||||
progress.visible = true
|
||||
}
|
||||
|
||||
private fun presentPendingBankTransferState(model: Model) {
|
||||
private fun presentPendingBankTransferState() {
|
||||
expiry.text = context.getString(R.string.MySupportPreference__payment_pending)
|
||||
progress.visible = true
|
||||
itemView.setOnClickListener { model.onPendingClick(model.price) }
|
||||
}
|
||||
|
||||
private fun presentInProgressState() {
|
||||
|
||||
@@ -294,9 +294,6 @@ class ManageDonationsFragment :
|
||||
subscriberRequiresCancel = state.subscriberRequiresCancel,
|
||||
onRowClick = {
|
||||
launcher.launch(InAppPaymentType.RECURRING_DONATION)
|
||||
},
|
||||
onPendingClick = {
|
||||
displayPendingDialog(it)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -317,7 +314,6 @@ class ManageDonationsFragment :
|
||||
onContactSupport = {},
|
||||
activeSubscription = null,
|
||||
subscriberRequiresCancel = state.subscriberRequiresCancel,
|
||||
onPendingClick = {},
|
||||
onRowClick = {}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,10 +35,10 @@ import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
import org.signal.core.util.getParcelableArrayListExtraCompat
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireParcelableCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog
|
||||
import org.thoughtcrime.securesms.MuteDialog
|
||||
import org.thoughtcrime.securesms.PushContactSelectionActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
@@ -69,9 +69,10 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.R
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
|
||||
@@ -119,15 +120,16 @@ private const val REQUEST_CODE_ADD_CONTACT = 2
|
||||
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
|
||||
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
|
||||
|
||||
class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.conversation_settings_fragment,
|
||||
menuId = R.menu.conversation_settings
|
||||
) {
|
||||
class ConversationSettingsFragment :
|
||||
DSLSettingsFragment(
|
||||
layoutId = R.layout.conversation_settings_fragment,
|
||||
menuId = R.menu.conversation_settings
|
||||
) {
|
||||
|
||||
private val args: ConversationSettingsFragmentArgs by navArgs()
|
||||
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
|
||||
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
|
||||
private val colorizer = Colorizer()
|
||||
private val colorizer = ColorizerV2()
|
||||
private val blockIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply {
|
||||
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
|
||||
@@ -189,6 +191,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
|
||||
val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java)
|
||||
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(AboutSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
|
||||
val groupId = bundle.requireParcelableCompat(AboutSheet.RESULT_GROUP_ID, GroupId.V2::class.java)
|
||||
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
|
||||
}
|
||||
|
||||
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
|
||||
}
|
||||
|
||||
@@ -469,9 +481,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
YouAreAlreadyInACallSnackbar.show(requireView())
|
||||
}
|
||||
},
|
||||
onMuteClick = {
|
||||
onMuteClick = { view ->
|
||||
if (!state.buttonStripState.isMuted) {
|
||||
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
|
||||
MuteContextMenu.show(view, requireView() as ViewGroup, childFragmentManager, viewLifecycleOwner) { duration ->
|
||||
viewModel.setMuteUntil(duration)
|
||||
}
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))
|
||||
@@ -739,7 +753,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = group,
|
||||
onClick = {
|
||||
onRowClick = {
|
||||
CommunicationActions.startConversation(requireActivity(), group, null)
|
||||
requireActivity().finish()
|
||||
}
|
||||
@@ -787,13 +801,26 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
|
||||
for (member in groupState.members) {
|
||||
val canSetMemberLabel = member.member.isSelf && groupState.canSetOwnMemberLabel
|
||||
val memberLabel = member.getMemberLabel(groupState)
|
||||
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = member.member,
|
||||
isAdmin = member.isAdmin,
|
||||
memberLabel = member.getMemberLabel(groupState),
|
||||
memberLabel = memberLabel,
|
||||
canSetMemberLabel = canSetMemberLabel,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
onClick = {
|
||||
onRowClick = {
|
||||
if (canSetMemberLabel && memberLabel == null) {
|
||||
val action = ConversationSettingsFragmentDirections
|
||||
.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
} else {
|
||||
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
|
||||
}
|
||||
},
|
||||
onAvatarClick = {
|
||||
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
|
||||
}
|
||||
)
|
||||
@@ -826,13 +853,17 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
|
||||
if (RemoteConfig.sendMemberLabels) {
|
||||
val canSetMemberLabel = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
isEnabled = canSetMemberLabel,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
},
|
||||
onDisabledClicked = {
|
||||
Snackbar.make(requireView(), R.string.GroupMemberLabel__error_no_edit_permission, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,8 @@ sealed class SpecificSettingsState {
|
||||
val membershipCountDescription: String = "",
|
||||
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE,
|
||||
val isAnnouncementGroup: Boolean = false,
|
||||
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap()
|
||||
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap(),
|
||||
val canSetOwnMemberLabel: Boolean = false
|
||||
) : SpecificSettingsState() {
|
||||
|
||||
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
|
||||
|
||||
@@ -362,6 +362,7 @@ sealed class ConversationSettingsViewModel(
|
||||
|
||||
if (groupId.isV2) {
|
||||
loadMemberLabels(groupId.requireV2(), fullMembers)
|
||||
loadCanSetMemberLabel(groupId.requireV2())
|
||||
}
|
||||
|
||||
state.copy(
|
||||
@@ -520,6 +521,17 @@ sealed class ConversationSettingsViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCanSetMemberLabel(v2GroupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
|
||||
val canSetLabel = MemberLabelRepository.instance.canSetLabel(v2GroupId, Recipient.self())
|
||||
store.update {
|
||||
it.copy(
|
||||
specificSettingsState = it.requireGroupSettingsState().copy(
|
||||
canSetOwnMemberLabel = canSetLabel
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object MuteContextMenu {
|
||||
|
||||
@JvmStatic
|
||||
fun show(anchor: View, container: ViewGroup, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: (Long) -> Unit): SignalContextMenu {
|
||||
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
|
||||
action(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
|
||||
}
|
||||
|
||||
val context = anchor.context
|
||||
val actionItems = listOf(
|
||||
ActionItem(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
|
||||
},
|
||||
ActionItem(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8))
|
||||
},
|
||||
ActionItem(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1))
|
||||
},
|
||||
ActionItem(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days)) {
|
||||
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7))
|
||||
},
|
||||
ActionItem(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until)) {
|
||||
MuteUntilTimePickerBottomSheet.show(fragmentManager)
|
||||
},
|
||||
ActionItem(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always)) {
|
||||
action(Long.MAX_VALUE)
|
||||
}
|
||||
)
|
||||
|
||||
return SignalContextMenu.Builder(anchor, container)
|
||||
.offsetX(12.dp)
|
||||
.offsetY(12.dp)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.show(actionItems)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import com.google.android.material.datepicker.CalendarConstraints
|
||||
import com.google.android.material.datepicker.DateValidatorPointForward
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.atMidnight
|
||||
import org.thoughtcrime.securesms.util.atUTC
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import java.util.Locale
|
||||
|
||||
class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "mute_until_result"
|
||||
const val RESULT_TIMESTAMP = "timestamp"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
val fragment = MuteUntilTimePickerBottomSheet()
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val context = LocalContext.current
|
||||
val now = remember { LocalDateTime.now() }
|
||||
|
||||
val defaultDateTime = remember {
|
||||
if (now.hour < 17) {
|
||||
now.withHour(17).withMinute(0).withSecond(0).withNano(0)
|
||||
} else {
|
||||
val nextMorning = if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY || now.dayOfWeek == DayOfWeek.SUNDAY) {
|
||||
now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
|
||||
} else {
|
||||
now.plusDays(1)
|
||||
}
|
||||
nextMorning.withHour(8).withMinute(0).withSecond(0).withNano(0)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedDate by remember { mutableLongStateOf(defaultDateTime.toMillis()) }
|
||||
var selectedHour by remember { mutableIntStateOf(defaultDateTime.hour) }
|
||||
var selectedMinute by remember { mutableIntStateOf(defaultDateTime.minute) }
|
||||
|
||||
val dateText = remember(selectedDate) {
|
||||
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), selectedDate)
|
||||
}
|
||||
|
||||
val timeText = remember(selectedHour, selectedMinute) {
|
||||
LocalTime.of(selectedHour, selectedMinute).formatHours(context)
|
||||
}
|
||||
|
||||
val zonedDateTime = remember { ZonedDateTime.now() }
|
||||
val timezoneDisclaimer = remember {
|
||||
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
|
||||
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
|
||||
context.getString(
|
||||
R.string.MuteUntilTimePickerBottomSheet__timezone_disclaimer,
|
||||
zoneOffsetFormatter.format(zonedDateTime),
|
||||
zoneNameFormatter.format(zonedDateTime)
|
||||
)
|
||||
}
|
||||
|
||||
MuteUntilSheetContent(
|
||||
dateText = dateText,
|
||||
timeText = timeText,
|
||||
timezoneDisclaimer = timezoneDisclaimer,
|
||||
onDateClick = {
|
||||
val local = LocalDateTime.now().atMidnight().atUTC().toMillis()
|
||||
val datePicker = MaterialDatePicker.Builder.datePicker()
|
||||
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_date_title))
|
||||
.setSelection(selectedDate)
|
||||
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
|
||||
.build()
|
||||
|
||||
datePicker.addOnDismissListener {
|
||||
datePicker.clearOnDismissListeners()
|
||||
datePicker.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
datePicker.addOnPositiveButtonClickListener {
|
||||
selectedDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
|
||||
}
|
||||
datePicker.show(childFragmentManager, "DATE_PICKER")
|
||||
},
|
||||
onTimeClick = {
|
||||
val timeFormat = if (DateFormat.is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
|
||||
val timePicker = MaterialTimePicker.Builder()
|
||||
.setTimeFormat(timeFormat)
|
||||
.setHour(selectedHour)
|
||||
.setMinute(selectedMinute)
|
||||
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_time_title))
|
||||
.build()
|
||||
|
||||
timePicker.addOnDismissListener {
|
||||
timePicker.clearOnDismissListeners()
|
||||
timePicker.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
timePicker.addOnPositiveButtonClickListener {
|
||||
selectedHour = timePicker.hour
|
||||
selectedMinute = timePicker.minute
|
||||
}
|
||||
timePicker.show(childFragmentManager, "TIME_PICKER")
|
||||
},
|
||||
onMuteClick = {
|
||||
val timestamp = selectedDate.toLocalDateTime()
|
||||
.withHour(selectedHour)
|
||||
.withMinute(selectedMinute)
|
||||
.withSecond(0)
|
||||
.withNano(0)
|
||||
.toMillis()
|
||||
|
||||
if (timestamp > System.currentTimeMillis()) {
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_TIMESTAMP to timestamp))
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteUntilSheetContent(
|
||||
dateText: String,
|
||||
timeText: String,
|
||||
timezoneDisclaimer: String,
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit,
|
||||
onMuteClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MuteUntilTimePickerBottomSheet__dialog_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 18.dp, bottom = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = timezoneDisclaimer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 56.dp)
|
||||
.align(Alignment.Start)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable(onClick = onDateClick)
|
||||
) {
|
||||
Text(
|
||||
text = dateText,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_expand_down_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable(onClick = onTimeClick)
|
||||
) {
|
||||
Text(
|
||||
text = timeText,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_expand_down_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 24.dp)
|
||||
) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = onMuteClick
|
||||
) {
|
||||
Text(stringResource(R.string.MuteUntilTimePickerBottomSheet__mute_notifications))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MuteUntilSheetContentPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
MuteUntilSheetContent(
|
||||
dateText = "Today",
|
||||
timeText = "5:00 PM",
|
||||
timezoneDisclaimer = "All times in (GMT-05:00) Eastern Standard Time",
|
||||
onDateClick = {},
|
||||
onTimeClick = {},
|
||||
onMuteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ object ButtonStripPreference {
|
||||
val onMessageClick: () -> Unit = {},
|
||||
val onVideoClick: () -> Unit = {},
|
||||
val onAudioClick: () -> Unit = {},
|
||||
val onMuteClick: () -> Unit = {},
|
||||
val onMuteClick: (View) -> Unit = {},
|
||||
val onSearchClick: () -> Unit = {}
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
@@ -97,7 +97,7 @@ object ButtonStripPreference {
|
||||
message.setOnClickListener { model.onMessageClick() }
|
||||
videoCall.setOnClickListener { model.onVideoClick() }
|
||||
audioCall.setOnClickListener { model.onAudioClick() }
|
||||
mute.setOnClickListener { model.onMuteClick() }
|
||||
mute.setOnClickListener { model.onMuteClick(it) }
|
||||
search.setOnClickListener { model.onSearchClick() }
|
||||
addToStory.setOnClickListener { model.onAddToStoryClick() }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.Observer
|
||||
@@ -36,8 +34,10 @@ object RecipientPreference {
|
||||
val recipient: Recipient,
|
||||
val isAdmin: Boolean = false,
|
||||
val memberLabel: StyledMemberLabel? = null,
|
||||
val canSetMemberLabel: Boolean = false,
|
||||
val lifecycleOwner: LifecycleOwner? = null,
|
||||
val onClick: (() -> Unit)? = null
|
||||
val onRowClick: (() -> Unit)? = null,
|
||||
val onAvatarClick: (() -> Unit)? = null
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipient.id == newItem.recipient.id
|
||||
@@ -47,7 +47,8 @@ object RecipientPreference {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
recipient.hasSameContent(newItem.recipient) &&
|
||||
isAdmin == newItem.isAdmin &&
|
||||
memberLabel == newItem.memberLabel
|
||||
memberLabel == newItem.memberLabel &&
|
||||
canSetMemberLabel == newItem.canSetMemberLabel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,28 +57,36 @@ object RecipientPreference {
|
||||
private val name: TextView = itemView.findViewById(R.id.recipient_name)
|
||||
private val about: TextView? = itemView.findViewById(R.id.recipient_about)
|
||||
private val memberLabelView: MemberLabelPillView? = itemView.findViewById(R.id.recipient_member_label)
|
||||
private val addMemberLabelView: TextView? = itemView.findViewById(R.id.add_member_label)
|
||||
private val admin: View? = itemView.findViewById(R.id.admin)
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.recipient_badge)
|
||||
|
||||
private var recipient: Recipient? = null
|
||||
private var canSetMemberLabel: Boolean = false
|
||||
|
||||
private val recipientObserver = Observer<Recipient> { recipient ->
|
||||
onRecipientChanged(recipient)
|
||||
onRecipientChanged(recipient = recipient, memberLabel = null, canSetMemberLabel = canSetMemberLabel)
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
if (model.onClick != null) {
|
||||
itemView.setOnClickListener { model.onClick.invoke() }
|
||||
if (model.onRowClick != null) {
|
||||
itemView.setOnClickListener { model.onRowClick.invoke() }
|
||||
} else {
|
||||
itemView.setOnClickListener(null)
|
||||
}
|
||||
|
||||
if (model.onAvatarClick != null) {
|
||||
avatar.setOnClickListener { model.onAvatarClick.invoke() }
|
||||
} else {
|
||||
avatar.setOnClickListener(null)
|
||||
}
|
||||
|
||||
canSetMemberLabel = model.canSetMemberLabel
|
||||
|
||||
if (model.lifecycleOwner != null) {
|
||||
observeRecipient(model.lifecycleOwner, model.recipient)
|
||||
model.memberLabel?.let(::showMemberLabel)
|
||||
} else {
|
||||
onRecipientChanged(model.recipient, model.memberLabel)
|
||||
}
|
||||
onRecipientChanged(model.recipient, model.memberLabel, model.canSetMemberLabel)
|
||||
|
||||
admin?.visible = model.isAdmin
|
||||
}
|
||||
@@ -86,7 +95,7 @@ object RecipientPreference {
|
||||
unbind()
|
||||
}
|
||||
|
||||
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null) {
|
||||
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null, canSetMemberLabel: Boolean = false) {
|
||||
avatar.setRecipient(recipient)
|
||||
badge.setBadgeFromRecipient(recipient)
|
||||
name.text = if (recipient.isSelf) {
|
||||
@@ -104,17 +113,17 @@ object RecipientPreference {
|
||||
}
|
||||
}
|
||||
|
||||
val aboutText = recipient.combinedAboutAndEmoji
|
||||
when {
|
||||
memberLabel != null -> showMemberLabel(memberLabel)
|
||||
|
||||
!recipient.combinedAboutAndEmoji.isNullOrEmpty() -> {
|
||||
about?.text = recipient.combinedAboutAndEmoji
|
||||
about?.visible = true
|
||||
memberLabelView?.visible = false
|
||||
}
|
||||
recipient.isSelf && canSetMemberLabel -> showAddMemberLabel()
|
||||
|
||||
!aboutText.isNullOrBlank() -> showAbout(aboutText)
|
||||
|
||||
else -> {
|
||||
memberLabelView?.visible = false
|
||||
addMemberLabelView?.visible = false
|
||||
about?.visible = false
|
||||
}
|
||||
}
|
||||
@@ -122,18 +131,29 @@ object RecipientPreference {
|
||||
|
||||
private fun showMemberLabel(styledLabel: StyledMemberLabel) {
|
||||
memberLabelView?.apply {
|
||||
style = MemberLabelPillView.Style(
|
||||
horizontalPadding = 8.dp,
|
||||
verticalPadding = 2.dp,
|
||||
textStyle = { MaterialTheme.typography.labelSmall }
|
||||
)
|
||||
style = MemberLabelPillView.Style.Compact
|
||||
setLabel(styledLabel.label, styledLabel.tintColor)
|
||||
visible = true
|
||||
}
|
||||
|
||||
addMemberLabelView?.visible = false
|
||||
about?.visible = false
|
||||
}
|
||||
|
||||
private fun showAddMemberLabel() {
|
||||
addMemberLabelView?.visible = true
|
||||
memberLabelView?.visible = false
|
||||
about?.visible = false
|
||||
}
|
||||
|
||||
private fun showAbout(text: String) {
|
||||
about?.text = text
|
||||
about?.visible = true
|
||||
|
||||
memberLabelView?.visible = false
|
||||
addMemberLabelView?.visible = false
|
||||
}
|
||||
|
||||
private fun observeRecipient(lifecycleOwner: LifecycleOwner?, recipient: Recipient?) {
|
||||
this.recipient?.live()?.liveData?.removeObserver(recipientObserver)
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
|
||||
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
|
||||
) {
|
||||
class SoundsAndNotificationsSettingsFragment :
|
||||
DSLSettingsFragment(
|
||||
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
|
||||
) {
|
||||
|
||||
private val mentionLabels: Array<String> by lazy {
|
||||
resources.getStringArray(R.array.SoundsAndNotificationsSettingsFragment__mention_labels)
|
||||
@@ -66,7 +67,7 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
|
||||
summary = DSLSettingsText.from(muteSummary),
|
||||
onClick = {
|
||||
if (state.muteUntil <= 0) {
|
||||
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
|
||||
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner, viewModel::setMuteUntil)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(muteSummary)
|
||||
|
||||
@@ -216,6 +216,16 @@ data class CallControlsState(
|
||||
val startCallButtonText: Int = R.string.WebRtcCallView__start_call,
|
||||
val displayEndCallButton: Boolean = false
|
||||
) {
|
||||
|
||||
val hasAnyControls: Boolean
|
||||
get() = displayAudioOutputToggle ||
|
||||
displayVideoToggle ||
|
||||
displayMicToggle ||
|
||||
displayGroupRingingToggle ||
|
||||
displayAdditionalActions ||
|
||||
displayStartCallButton ||
|
||||
displayEndCallButton
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Presentation-level method to build out the controls state from legacy objects.
|
||||
|
||||
@@ -16,11 +16,11 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
@@ -35,6 +35,31 @@ import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Mutable holder for bar dimensions, used to pass measurement results from
|
||||
* BlurrableContentLayer to PipLayer during the same Layout measurement pass
|
||||
* without requiring SubcomposeLayout.
|
||||
*/
|
||||
private class BarDimensions {
|
||||
var heightPx: Int = 0
|
||||
var widthPx: Int = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Arranges call screen content in coordinated layers so the local PiP can avoid centered bars.
|
||||
*
|
||||
* @param callGridSlot Main call grid content.
|
||||
* @param pictureInPictureSlot Local participant PiP content.
|
||||
* @param reactionsSlot Reactions overlay content.
|
||||
* @param raiseHandSlot Slot for the raised-hand bar.
|
||||
* @param callLinkBarSlot Slot for the call-link bar.
|
||||
* @param callOverflowSlot Overflow participants strip.
|
||||
* @param audioIndicatorSlot Participant audio indicator content.
|
||||
* @param bottomInset Bottom inset used to keep content clear of anchored UI.
|
||||
* @param bottomSheetWidth Maximum width of centered bottom content.
|
||||
* @param localRenderState Current local renderer mode.
|
||||
* @param modifier Modifier applied to the root layout.
|
||||
*/
|
||||
@Composable
|
||||
fun CallElementsLayout(
|
||||
callGridSlot: @Composable () -> Unit,
|
||||
@@ -71,48 +96,46 @@ fun CallElementsLayout(
|
||||
bottomSheetWidth.roundToPx()
|
||||
}
|
||||
|
||||
SubcomposeLayout(modifier = modifier) { constraints ->
|
||||
// Holder to capture measurements from BlurrableContentLayer
|
||||
var measuredBarsHeightPx = 0
|
||||
var measuredBarsWidthPx = 0
|
||||
val barDimensions = remember { BarDimensions() }
|
||||
|
||||
// Subcompose and measure the blurrable layer first - it will measure all content internally
|
||||
// and report back the bars dimensions via the onMeasured callback
|
||||
val blurrableLayerPlaceable = subcompose("blurrable") {
|
||||
BlurrableContentLayer(
|
||||
isFocused = isFocused,
|
||||
isPortrait = isPortrait,
|
||||
bottomInsetPx = bottomInsetPx,
|
||||
bottomSheetWidthPx = bottomSheetWidthPx,
|
||||
barsSlot = { Bars() },
|
||||
callGridSlot = callGridSlot,
|
||||
reactionsSlot = reactionsSlot,
|
||||
callOverflowSlot = callOverflowSlot,
|
||||
audioIndicatorSlot = audioIndicatorSlot,
|
||||
onMeasured = { barsHeight, barsWidth ->
|
||||
measuredBarsHeightPx = barsHeight
|
||||
measuredBarsWidthPx = barsWidth
|
||||
}
|
||||
)
|
||||
}.map { it.measure(constraints) }
|
||||
|
||||
// Use the wider of bars or bottom sheet for space calculation
|
||||
val centeredContentWidthPx = maxOf(measuredBarsWidthPx, bottomSheetWidthPx)
|
||||
|
||||
val pipLayerPlaceable = subcompose("pip") {
|
||||
PipLayer(
|
||||
pictureInPictureSlot = pictureInPictureSlot,
|
||||
localRenderState = localRenderState,
|
||||
bottomInsetPx = bottomInsetPx,
|
||||
barsHeightPx = measuredBarsHeightPx,
|
||||
pipSizePx = pipSizePx,
|
||||
centeredContentWidthPx = centeredContentWidthPx
|
||||
)
|
||||
}.map { it.measure(constraints) }
|
||||
Layout(
|
||||
contents = listOf(
|
||||
{
|
||||
BlurrableContentLayer(
|
||||
isFocused = isFocused,
|
||||
isPortrait = isPortrait,
|
||||
bottomInsetPx = bottomInsetPx,
|
||||
bottomSheetWidthPx = bottomSheetWidthPx,
|
||||
barsSlot = { Bars() },
|
||||
callGridSlot = callGridSlot,
|
||||
reactionsSlot = reactionsSlot,
|
||||
callOverflowSlot = callOverflowSlot,
|
||||
audioIndicatorSlot = audioIndicatorSlot,
|
||||
onMeasured = { barsHeight, barsWidth ->
|
||||
barDimensions.heightPx = barsHeight
|
||||
barDimensions.widthPx = barsWidth
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
PipLayer(
|
||||
pictureInPictureSlot = pictureInPictureSlot,
|
||||
localRenderState = localRenderState,
|
||||
bottomInsetPx = bottomInsetPx,
|
||||
barDimensions = barDimensions,
|
||||
pipSizePx = pipSizePx,
|
||||
bottomSheetWidthPx = bottomSheetWidthPx
|
||||
)
|
||||
}
|
||||
),
|
||||
modifier = modifier
|
||||
) { (blurrableMeasurables, pipMeasurables), constraints ->
|
||||
val blurrablePlaceables = blurrableMeasurables.map { it.measure(constraints) }
|
||||
val pipPlaceables = pipMeasurables.map { it.measure(constraints) }
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
blurrableLayerPlaceable.forEach { it.place(0, 0) }
|
||||
pipLayerPlaceable.forEach { it.place(0, 0) }
|
||||
blurrablePlaceables.forEach { it.place(0, 0) }
|
||||
pipPlaceables.forEach { it.place(0, 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +192,6 @@ private fun BlurrableContentLayer(
|
||||
nonOverflowConstraints
|
||||
}
|
||||
|
||||
// Cap bars width to sheet max width (bars can be narrower if content doesn't fill)
|
||||
val barsMaxWidth = minOf(barConstraints.maxWidth, bottomSheetWidthPx)
|
||||
val barsConstrainedToSheet = barConstraints.copy(maxWidth = barsMaxWidth)
|
||||
|
||||
@@ -177,7 +199,6 @@ private fun BlurrableContentLayer(
|
||||
val barsHeightPx = barsPlaceables.sumOf { it.height }
|
||||
val barsWidthPx = barsPlaceables.maxOfOrNull { it.width } ?: 0
|
||||
|
||||
// Report measurements to parent for PipLayer positioning
|
||||
onMeasured(barsHeightPx, barsWidthPx)
|
||||
|
||||
val reactionsConstraints = barConstraints.offset(vertical = -barsHeightPx)
|
||||
@@ -229,9 +250,9 @@ private fun PipLayer(
|
||||
pictureInPictureSlot: @Composable () -> Unit,
|
||||
localRenderState: WebRtcLocalRenderState,
|
||||
bottomInsetPx: Int,
|
||||
barsHeightPx: Int,
|
||||
barDimensions: BarDimensions,
|
||||
pipSizePx: Size,
|
||||
centeredContentWidthPx: Int
|
||||
bottomSheetWidthPx: Int
|
||||
) {
|
||||
Layout(
|
||||
content = pictureInPictureSlot,
|
||||
@@ -239,13 +260,14 @@ private fun PipLayer(
|
||||
) { measurables, constraints ->
|
||||
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
|
||||
val centeredContentWidthPx = maxOf(barDimensions.widthPx, bottomSheetWidthPx)
|
||||
|
||||
val pictureInPictureConstraints: Constraints = when (localRenderState) {
|
||||
WebRtcLocalRenderState.GONE, WebRtcLocalRenderState.SMALLER_RECTANGLE, WebRtcLocalRenderState.LARGE, WebRtcLocalRenderState.LARGE_NO_VIDEO, WebRtcLocalRenderState.FOCUSED -> constraints
|
||||
WebRtcLocalRenderState.SMALL_RECTANGLE, WebRtcLocalRenderState.EXPANDED -> {
|
||||
// Check if there's enough space on either side of the centered content (bars/sheet)
|
||||
val spaceOnEachSide = (looseConstraints.maxWidth - centeredContentWidthPx) / 2
|
||||
val shouldOffset = centeredContentWidthPx > 0 && spaceOnEachSide < pipSizePx.width
|
||||
val offsetAmount = bottomInsetPx + barsHeightPx
|
||||
val offsetAmount = bottomInsetPx + barDimensions.heightPx
|
||||
looseConstraints.offset(vertical = if (shouldOffset) -offsetAmount else 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +530,9 @@ fun <T> CallGrid(
|
||||
val hasExistingItems = knownKeys.isNotEmpty()
|
||||
|
||||
newKeys.forEach { key ->
|
||||
if (exitingItems.any { it.key == key }) {
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
}
|
||||
if (hasExistingItems) {
|
||||
alphaAnimatables[key] = Animatable(0f)
|
||||
scaleAnimatables[key] = Animatable(CallGridDefaults.ENTER_SCALE_START)
|
||||
@@ -569,7 +572,9 @@ fun <T> CallGrid(
|
||||
launch { scaleAnimatables[key]?.animateTo(CallGridDefaults.EXIT_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
|
||||
}
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
removeAnimationState(key)
|
||||
if (key !in knownKeys) {
|
||||
removeAnimationState(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
removeAnimationState(key)
|
||||
|
||||
@@ -180,11 +180,12 @@ fun CallScreen(
|
||||
val maxOffset = maxHeight - maxSheetHeight
|
||||
|
||||
var peekHeight by remember { mutableFloatStateOf(88f) }
|
||||
val effectivePeekHeight = if (callControlsState.hasAnyControls) peekHeight else 0f
|
||||
|
||||
BottomSheetScaffold(
|
||||
scaffoldState = callScreenController.scaffoldState,
|
||||
sheetDragHandle = null,
|
||||
sheetPeekHeight = peekHeight.dp,
|
||||
sheetPeekHeight = effectivePeekHeight.dp,
|
||||
sheetContainerColor = SignalTheme.colors.colorSurface1,
|
||||
containerColor = Color.Black,
|
||||
sheetMaxWidth = CallScreenMetrics.SheetMaxWidth,
|
||||
|
||||
@@ -1110,18 +1110,18 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
|
||||
/**
|
||||
* Controls lock screen and screen-on behavior based on call state.
|
||||
* - Show over lock screen: Only for incoming ringing calls, so user can answer.
|
||||
* - Show over lock screen: For any ongoing call state, so the call UI remains visible
|
||||
* if the call was answered from the lock screen.
|
||||
* - Turn screen on: For any ongoing call state, so screen stays on during call.
|
||||
*/
|
||||
private fun setTurnScreenOnForCallState(callState: WebRtcViewModel.State) {
|
||||
val isIncomingRinging = callState == WebRtcViewModel.State.CALL_INCOMING
|
||||
val isOngoingCall = callState.inOngoingCall
|
||||
if (Build.VERSION.SDK_INT >= 27) {
|
||||
setShowWhenLocked(isIncomingRinging)
|
||||
setShowWhenLocked(isOngoingCall)
|
||||
setTurnScreenOn(isOngoingCall)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
if (isIncomingRinging) {
|
||||
if (isOngoingCall) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -83,7 +84,7 @@ class WebRtcCallViewModel : ViewModel() {
|
||||
|
||||
private val groupMemberStateUpdater = FlowCollector<List<GroupMemberEntry.FullMember>> { m -> participantsState.update { CallParticipantsState.update(it, m) } }
|
||||
|
||||
private val shouldShowSpeakerHint: Flow<Boolean> = participantsState.map(this::shouldShowSpeakerHint)
|
||||
private val shouldShowSpeakerHint: Flow<Boolean> = participantsState.map(this::shouldShowSpeakerHint).distinctUntilChanged()
|
||||
|
||||
private val elapsedTimeHandler = Handler(Looper.getMainLooper())
|
||||
private val elapsedTimeRunnable = Runnable { handleTick() }
|
||||
@@ -174,6 +175,7 @@ class WebRtcCallViewModel : ViewModel() {
|
||||
0
|
||||
}
|
||||
}
|
||||
.onStart { emit(0) }
|
||||
|
||||
return combine(
|
||||
callParticipantsState,
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import java.io.IOException
|
||||
import java.util.Calendar
|
||||
import java.util.LinkedList
|
||||
|
||||
/**
|
||||
* Methods for discovering which users are registered and marking them as such in the database.
|
||||
@@ -208,7 +209,7 @@ object ContactDiscovery {
|
||||
if (!SignalStore.settings.isNotifyWhenContactJoinsSignal) return
|
||||
|
||||
Recipient.resolvedList(newUserIds)
|
||||
.filter { !it.isSelf && it.hasAUserSetDisplayName(context) && !hasSession(it.id) && it.hasE164 && !it.isBlocked }
|
||||
.filter { !it.isSelf && !it.isHidden && it.hasAUserSetDisplayName(context) && !hasSession(it.id) && it.hasE164 && !it.isBlocked }
|
||||
.map {
|
||||
Log.i(TAG, "Inserting 'contact joined' message for ${it.id}. E164: ${it.e164}")
|
||||
val message = IncomingMessage.contactJoined(it.id, System.currentTimeMillis())
|
||||
@@ -240,7 +241,8 @@ object ContactDiscovery {
|
||||
clearInfoForMissingContacts: Boolean
|
||||
) {
|
||||
val localNumber: String = SignalStore.account.e164 ?: ""
|
||||
val handle = SignalDatabase.recipients.beginBulkSystemContactUpdate(clearInfoForMissingContacts)
|
||||
|
||||
val contactInfos = LinkedList<ContactInfo>()
|
||||
try {
|
||||
contactsProvider().use { iterator ->
|
||||
while (iterator.hasNext()) {
|
||||
@@ -262,22 +264,42 @@ object ContactDiscovery {
|
||||
|
||||
val recipient: Recipient = Recipient.externalContact(realNumber) ?: continue
|
||||
|
||||
handle.setSystemContactInfo(
|
||||
recipient.id,
|
||||
profileName,
|
||||
phoneDetails.displayName,
|
||||
phoneDetails.photoUri,
|
||||
phoneDetails.label,
|
||||
phoneDetails.type,
|
||||
phoneDetails.contactUri.toString()
|
||||
contactInfos.add(
|
||||
ContactInfo(
|
||||
recipientId = recipient.id,
|
||||
profileName = profileName,
|
||||
displayName = phoneDetails.displayName,
|
||||
photoUri = phoneDetails.photoUri,
|
||||
label = phoneDetails.label,
|
||||
type = phoneDetails.type,
|
||||
contactUri = phoneDetails.contactUri.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "Hit an issue with the cursor while reading!", e)
|
||||
} finally {
|
||||
handle.finish()
|
||||
}
|
||||
|
||||
if (contactInfos.isNotEmpty()) {
|
||||
val handle = SignalDatabase.recipients.beginBulkSystemContactUpdate(clearInfoForMissingContacts)
|
||||
try {
|
||||
for (contactInfo in contactInfos) {
|
||||
handle.setSystemContactInfo(
|
||||
id = contactInfo.recipientId,
|
||||
systemProfileName = contactInfo.profileName,
|
||||
systemDisplayName = contactInfo.displayName,
|
||||
photoUri = contactInfo.photoUri,
|
||||
systemPhoneLabel = contactInfo.label,
|
||||
systemPhoneType = contactInfo.type,
|
||||
systemContactUri = contactInfo.contactUri
|
||||
)
|
||||
}
|
||||
contactInfos.clear()
|
||||
} finally {
|
||||
handle.finish()
|
||||
}
|
||||
}
|
||||
|
||||
if (NotificationChannels.supported()) {
|
||||
@@ -318,4 +340,14 @@ object ContactDiscovery {
|
||||
val pni: ServiceId.PNI,
|
||||
val aci: ServiceId.ACI?
|
||||
)
|
||||
|
||||
private class ContactInfo(
|
||||
val recipientId: RecipientId,
|
||||
val profileName: ProfileName,
|
||||
val displayName: String?,
|
||||
val photoUri: String?,
|
||||
val label: String?,
|
||||
val type: Int,
|
||||
val contactUri: String
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.WindowCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Activity which encapsulates a conversation for a Bubble window.
|
||||
*
|
||||
*8
|
||||
* This activity exists so that we can override some of its manifest parameters
|
||||
* without clashing with [ConversationActivity] and provide an API-level
|
||||
* independent "is in bubble?" check.
|
||||
*/
|
||||
class BubbleConversationActivity : ConversationActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
ViewUtil.hideKeyboard(this, findViewById(R.id.fragment_container))
|
||||
|
||||
@@ -46,7 +46,7 @@ import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
@@ -113,7 +113,7 @@ public class ConversationAdapter
|
||||
private boolean hasWallpaper;
|
||||
private boolean isMessageRequestAccepted;
|
||||
private ConversationMessage inlineContent;
|
||||
private Colorizer colorizer;
|
||||
private ColorizerV1 colorizer;
|
||||
private boolean isTypingViewEnabled;
|
||||
private ConversationItemDisplayMode displayMode;
|
||||
private PulseRequest pulseRequest;
|
||||
@@ -124,7 +124,7 @@ public class ConversationAdapter
|
||||
@NonNull Locale locale,
|
||||
@Nullable ItemClickListener clickListener,
|
||||
boolean hasWallpaper,
|
||||
@NonNull Colorizer colorizer)
|
||||
@NonNull ColorizerV1 colorizer)
|
||||
{
|
||||
super(new DiffUtil.ItemCallback<ConversationMessage>() {
|
||||
@Override
|
||||
|
||||
@@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.database.model.Quote;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
|
||||
@@ -147,6 +148,7 @@ import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.ProjectionList;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
import org.signal.core.ui.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.UrlClickHandler;
|
||||
@@ -1093,7 +1095,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
bodyText.setOverflowText(null);
|
||||
bodyText.setMaxLength(-1);
|
||||
|
||||
if (messageRecord.isRemoteDelete()) {
|
||||
if (RemoteConfig.receiveAdminDelete() && conversationMessage.getDeletedByRecipient() != null) {
|
||||
bodyText.setText(getDeletedMessageText(conversationMessage));
|
||||
bodyText.setVisibility(View.VISIBLE);
|
||||
bodyText.setOverflowText(null);
|
||||
} else if (messageRecord.isRemoteDelete()) {
|
||||
String deletedMessage = context.getString(messageRecord.isOutgoing() ? R.string.ConversationItem_you_deleted_this_message : R.string.ConversationItem_this_message_was_deleted);
|
||||
SpannableString italics = new SpannableString(deletedMessage);
|
||||
italics.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
@@ -1156,6 +1162,44 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private SpannableStringBuilder getDeletedMessageText(@NonNull ConversationMessage message) {
|
||||
boolean isAdminDelete = !message.getDeletedByRecipient().equals(message.getMessageRecord().getFromRecipient());
|
||||
CharSequence body;
|
||||
|
||||
if (!isAdminDelete && messageRecord.isOutgoing()) {
|
||||
body = formatDeletedText(context.getString(R.string.ConversationItem_you_deleted_this_message));
|
||||
} else if (!isAdminDelete) {
|
||||
body = formatDeletedText(context.getString(R.string.ConversationItem_s_deleted_this_message, message.getDeletedByRecipient().getDisplayName(context)));
|
||||
} else {
|
||||
SpannableString prefix = formatDeletedText(context.getString(R.string.ConversationItem_admin));
|
||||
SpannableString suffix = formatDeletedText(context.getString(R.string.ConversationItem_deleted_this_message));
|
||||
|
||||
int nameColor = colorizer.getIncomingGroupSenderColor(getContext(), message.getDeletedByRecipient());
|
||||
SpannableString name = new SpannableString(message.getDeletedByRecipient().getDisplayName(context));
|
||||
name.setSpan(new ForegroundColorSpan(nameColor), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
name.setSpan(new RecipientClickableSpan(conversationMessage.getDeletedByRecipient().getId()), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
name.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
body = new SpannableStringBuilder()
|
||||
.append(prefix)
|
||||
.append(" ")
|
||||
.append(name)
|
||||
.append(" ")
|
||||
.append(suffix);
|
||||
}
|
||||
|
||||
return new SpannableStringBuilder()
|
||||
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.X_CIRCLE, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant))
|
||||
.append(" ")
|
||||
.append(body);
|
||||
}
|
||||
|
||||
private SpannableString formatDeletedText(String text) {
|
||||
SpannableString spannableString = new SpannableString(text);
|
||||
spannableString.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant)), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannableString;
|
||||
}
|
||||
|
||||
private void setMediaAttributes(@NonNull MessageRecord messageRecord,
|
||||
@NonNull Optional<MessageRecord> previousRecord,
|
||||
@NonNull Optional<MessageRecord> nextRecord,
|
||||
@@ -1668,7 +1712,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody);
|
||||
for (Annotation annotation : mentionAnnotations) {
|
||||
messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
messageBody.setSpan(new RecipientClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2895,18 +2939,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private class MentionClickableSpan extends ClickableSpan {
|
||||
private final RecipientId mentionedRecipientId;
|
||||
private class RecipientClickableSpan extends ClickableSpan {
|
||||
private final RecipientId recipientId;
|
||||
|
||||
MentionClickableSpan(RecipientId mentionedRecipientId) {
|
||||
this.mentionedRecipientId = mentionedRecipientId;
|
||||
RecipientClickableSpan(RecipientId recipientId) {
|
||||
this.recipientId = recipientId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(@NonNull View widget) {
|
||||
if (eventListener != null && batchSelected.isEmpty()) {
|
||||
VibrateUtil.vibrateTick(context);
|
||||
eventListener.onGroupMemberClicked(mentionedRecipientId, conversationRecipient.get().requireGroupId());
|
||||
eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ public class ConversationMessage {
|
||||
@NonNull private final ComputedProperties computedProperties;
|
||||
@Nullable private final MemberLabel memberLabel;
|
||||
@Nullable private final MemberLabel quoteMemberLabel;
|
||||
@Nullable private final Recipient deletedByRecipient;
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord,
|
||||
@Nullable CharSequence body,
|
||||
@@ -61,7 +62,8 @@ public class ConversationMessage {
|
||||
@Nullable MessageRecord originalMessage,
|
||||
@NonNull ComputedProperties computedProperties,
|
||||
@Nullable MemberLabel memberLabel,
|
||||
@Nullable MemberLabel quoteMemberLabel)
|
||||
@Nullable MemberLabel quoteMemberLabel,
|
||||
@Nullable Recipient deletedByRecipient)
|
||||
{
|
||||
this.messageRecord = messageRecord;
|
||||
this.hasBeenQuoted = hasBeenQuoted;
|
||||
@@ -72,6 +74,7 @@ public class ConversationMessage {
|
||||
this.computedProperties = computedProperties;
|
||||
this.memberLabel = memberLabel;
|
||||
this.quoteMemberLabel = quoteMemberLabel;
|
||||
this.deletedByRecipient = deletedByRecipient;
|
||||
|
||||
if (body != null) {
|
||||
this.body = SpannableString.valueOf(body);
|
||||
@@ -116,6 +119,10 @@ public class ConversationMessage {
|
||||
return quoteMemberLabel;
|
||||
}
|
||||
|
||||
public @Nullable Recipient getDeletedByRecipient() {
|
||||
return deletedByRecipient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
@@ -252,6 +259,7 @@ public class ConversationMessage {
|
||||
FormattedDate formattedDate = getFormattedDate(context, messageRecord);
|
||||
MemberLabel memberLabel = getMemberLabel(messageRecord, threadRecipient);
|
||||
MemberLabel quoteMemberLabel = getQuoteMemberLabel(messageRecord, threadRecipient);
|
||||
Recipient deletedBy = messageRecord.getDeletedBy() != null ? Recipient.resolved(messageRecord.getDeletedBy()) : null;
|
||||
|
||||
return new ConversationMessage(messageRecord,
|
||||
styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body,
|
||||
@@ -262,7 +270,8 @@ public class ConversationMessage {
|
||||
originalMessage,
|
||||
new ComputedProperties(formattedDate),
|
||||
memberLabel,
|
||||
quoteMemberLabel);
|
||||
quoteMemberLabel,
|
||||
deletedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,7 +119,7 @@ private fun NewConversationScreen(
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val callbacks = remember {
|
||||
object : UiCallbacks {
|
||||
object : NewConversationUiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
|
||||
override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.createIntent(context))
|
||||
override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME)
|
||||
@@ -189,7 +189,7 @@ private suspend fun openConversation(
|
||||
@Composable
|
||||
private fun NewConversationScreenUi(
|
||||
uiState: NewConversationUiState,
|
||||
callbacks: UiCallbacks
|
||||
callbacks: NewConversationUiCallbacks
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
@@ -221,7 +221,7 @@ private fun NewConversationScreenUi(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBarActions(callbacks: UiCallbacks) {
|
||||
private fun TopAppBarActions(callbacks: NewConversationUiCallbacks) {
|
||||
val menuController = remember { DropdownMenus.MenuController() }
|
||||
IconButton(
|
||||
onClick = { menuController.show() },
|
||||
@@ -265,7 +265,7 @@ private fun TopAppBarActions(callbacks: UiCallbacks) {
|
||||
}
|
||||
}
|
||||
|
||||
private interface UiCallbacks :
|
||||
private interface NewConversationUiCallbacks :
|
||||
RecipientPickerCallbacks.ListActions,
|
||||
RecipientPickerCallbacks.Refresh,
|
||||
RecipientPickerCallbacks.ContextMenu,
|
||||
@@ -278,7 +278,7 @@ private interface UiCallbacks :
|
||||
fun onUserMessageDismissed(userMessage: UserMessage)
|
||||
fun onBackPressed()
|
||||
|
||||
object Empty : UiCallbacks {
|
||||
object Empty : NewConversationUiCallbacks {
|
||||
override fun onSearchQueryChanged(query: String) = Unit
|
||||
override fun onCreateNewGroup() = Unit
|
||||
override fun onFindByUsername() = Unit
|
||||
@@ -303,7 +303,7 @@ private interface UiCallbacks :
|
||||
@Composable
|
||||
private fun NewConversationRecipientPicker(
|
||||
uiState: NewConversationUiState,
|
||||
callbacks: UiCallbacks,
|
||||
callbacks: NewConversationUiCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
RecipientPicker(
|
||||
@@ -400,7 +400,7 @@ private fun NewConversationScreenPreview() {
|
||||
uiState = NewConversationUiState(
|
||||
forceSplitPaneOnCompactLandscape = false
|
||||
),
|
||||
callbacks = UiCallbacks.Empty
|
||||
callbacks = NewConversationUiCallbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
@@ -74,7 +74,8 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
val colorizer = Colorizer()
|
||||
@Suppress("DEPRECATION")
|
||||
val colorizer = ColorizerV1()
|
||||
|
||||
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
|
||||
setCondensedMode(ConversationItemDisplayMode.Condensed(ConversationItemDisplayMode.MessageMode.PINNED))
|
||||
|
||||
@@ -8,8 +8,11 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.time.DayOfWeek
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
class ScheduleMessageContextMenu {
|
||||
|
||||
@@ -53,7 +56,7 @@ class ScheduleMessageContextMenu {
|
||||
private fun getNextScheduleTimes(currentTimeMs: Long): List<Long> {
|
||||
var currentDateTime = currentTimeMs.toLocalDateTime()
|
||||
|
||||
val timestampList = ArrayList<Long>(4)
|
||||
val timestampList = ArrayList<Long>(5)
|
||||
var presetIndex = presetHours.indexOfFirst { it > currentDateTime.hour }
|
||||
if (presetIndex == -1) {
|
||||
currentDateTime = currentDateTime.plusDays(1)
|
||||
@@ -69,6 +72,18 @@ class ScheduleMessageContextMenu {
|
||||
currentDateTime = currentDateTime.plusDays(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
val now = currentTimeMs.toLocalDateTime()
|
||||
if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY) {
|
||||
val nextMonday = now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
|
||||
.withHour(8)
|
||||
.withMinute(0)
|
||||
.withSecond(0)
|
||||
timestampList += nextMonday.toMillis()
|
||||
}
|
||||
}
|
||||
|
||||
timestampList += -1
|
||||
|
||||
return timestampList.reversed()
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.components.SignalProgressDialog
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart.Attachments
|
||||
@@ -47,7 +47,7 @@ import org.thoughtcrime.securesms.mms.TextSlide
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import org.thoughtcrime.securesms.util.hasTextSlide
|
||||
import org.thoughtcrime.securesms.util.requireTextSlide
|
||||
import java.io.IOException
|
||||
@@ -88,9 +88,13 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
callback = requireListener()
|
||||
callback = findListener<ConversationBottomSheetCallback>() ?: run {
|
||||
dismissAllowingStateLoss()
|
||||
return
|
||||
}
|
||||
|
||||
val colorizer = Colorizer()
|
||||
@Suppress("DEPRECATION")
|
||||
val colorizer = ColorizerV1()
|
||||
|
||||
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
|
||||
setCondensedMode(ConversationItemDisplayMode.Condensed(ConversationItemDisplayMode.MessageMode.SCHEDULED))
|
||||
|
||||
@@ -14,19 +14,28 @@ import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||
|
||||
import org.signal.core.models.ServiceId;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2;
|
||||
import org.thoughtcrime.securesms.conversation.colors.NameColor;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel;
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelRepository;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.signal.core.ui.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
@@ -68,7 +77,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
|
||||
|
||||
GroupMemberListView list = view.findViewById(R.id.show_admin_list);
|
||||
list.initializeAdapter(getViewLifecycleOwner());
|
||||
list.setDisplayOnlyMembers(Collections.emptyList());
|
||||
list.setMembers(Collections.emptyList());
|
||||
|
||||
list.setRecipientClickListener(recipient -> {
|
||||
CommunicationActions.startConversation(requireContext(), recipient, null);
|
||||
@@ -78,7 +87,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
|
||||
disposables.add(Single.fromCallable(() -> getAdmins(requireContext().getApplicationContext(), getGroupId()))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(list::setDisplayOnlyMembers));
|
||||
.subscribe(list::setMembers));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -97,10 +106,23 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private static @NonNull List<Recipient> getAdmins(@NonNull Context context, @NonNull GroupId groupId) {
|
||||
return SignalDatabase.groups()
|
||||
.getGroup(groupId)
|
||||
.map(GroupRecord::getAdmins)
|
||||
.orElse(Collections.emptyList());
|
||||
private static @NonNull List<GroupMemberEntry> getAdmins(@NonNull Context context, @NonNull GroupId groupId) {
|
||||
GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId).orElse(null);
|
||||
if (groupRecord == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Recipient> admins = groupRecord.getAdmins();
|
||||
Map<RecipientId, MemberLabel> labelsByRecipientId = MemberLabelRepository.getInstance().getLabelsJava(groupId.requireV2(), admins);
|
||||
List<ServiceId> memberIds = groupRecord.requireV2GroupProperties().getMemberServiceIds();
|
||||
ColorizerV2 colorizer = new ColorizerV2(memberIds);
|
||||
|
||||
List<GroupMemberEntry> result = new ArrayList<>();
|
||||
for (Recipient admin : admins) {
|
||||
MemberLabel label = labelsByRecipientId.get(admin.getId());
|
||||
NameColor nameColor = label != null ? colorizer.getNameColor(context, admin) : null;
|
||||
result.add(new GroupMemberEntry.FullMember(admin, true, label, nameColor));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,18 @@ import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Helper class for all things ChatColors.
|
||||
* Provides conversation bubble and sender name colors.
|
||||
*
|
||||
* - Maintains a mapping for group recipient colors
|
||||
* - Gives easy access to different bubble colors
|
||||
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView
|
||||
* Use [ColorizerV2] for new CFv2 code, and [ColorizerV1] for legacy CFv1 code.
|
||||
*/
|
||||
class Colorizer {
|
||||
|
||||
private var colorsHaveBeenSet = false
|
||||
|
||||
@Deprecated("Not needed for CFv2")
|
||||
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
|
||||
|
||||
private val groupMembers: LinkedHashSet<ServiceId> = linkedSetOf()
|
||||
|
||||
interface Colorizer {
|
||||
@ColorInt
|
||||
fun getOutgoingBodyTextColor(context: Context): Int {
|
||||
return ContextCompat.getColor(context, R.color.conversation_outgoing_body_color)
|
||||
@@ -67,46 +58,95 @@ class Colorizer {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@ColorInt
|
||||
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int {
|
||||
return if (groupMembers.isEmpty()) {
|
||||
groupSenderColors[recipient.id]?.getColor(context) ?: getDefaultColor(context, recipient)
|
||||
} else if (recipient.hasServiceId) {
|
||||
val memberPosition = groupMembers.indexOf(recipient.requireServiceId())
|
||||
|
||||
if (memberPosition >= 0) {
|
||||
val colorPosition = memberPosition % ChatColorsPalette.Names.all.size
|
||||
ChatColorsPalette.Names.all[colorPosition].getColor(context)
|
||||
} else {
|
||||
getDefaultColor(context, recipient)
|
||||
}
|
||||
} else {
|
||||
getDefaultColor(context, recipient)
|
||||
}
|
||||
return getNameColor(context, recipient).getColor(context)
|
||||
}
|
||||
|
||||
fun onGroupMembershipChanged(serviceIds: List<ServiceId>) {
|
||||
groupMembers.addAll(serviceIds.sortedBy { it.toString() })
|
||||
}
|
||||
fun getNameColor(context: Context, recipient: Recipient): NameColor
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Not needed for CFv2", ReplaceWith("onGroupMembershipChanged"))
|
||||
/**
|
||||
* [Colorizer] implementation for CFv1 (legacy ConversationFragment).
|
||||
*
|
||||
* Colors are pre-assigned via [onNameColorsChanged] using a static [RecipientId] → [NameColor] map.
|
||||
*
|
||||
* See [ColorizerV2] for the CFv2 position-based approach.
|
||||
*/
|
||||
@Deprecated("Use ColorizerV2 instead. This class only exists to support the legacy CFv1.")
|
||||
class ColorizerV1 : Colorizer {
|
||||
private var colorsHaveBeenSet = false
|
||||
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Replaces the entire mapping of group member IDs to name colors.
|
||||
*
|
||||
* Must be called before [getNameColor] to ensure colors are assigned correctly.
|
||||
*/
|
||||
fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
|
||||
groupSenderColors.clear()
|
||||
groupSenderColors.putAll(nameColorMap)
|
||||
colorsHaveBeenSet = true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@ColorInt
|
||||
private fun getDefaultColor(context: Context, recipient: Recipient): Int {
|
||||
return if (colorsHaveBeenSet) {
|
||||
val color = ChatColorsPalette.Names.all[groupSenderColors.size % ChatColorsPalette.Names.all.size]
|
||||
groupSenderColors[recipient.id] = color
|
||||
return color.getColor(context)
|
||||
} else {
|
||||
getIncomingBodyTextColor(context, recipient.hasWallpaper)
|
||||
/**
|
||||
* Returns the name color for the given recipient based on their position in the group member list.
|
||||
*/
|
||||
override fun getNameColor(context: Context, recipient: Recipient): NameColor {
|
||||
val assignedColor = groupSenderColors[recipient.id]
|
||||
if (assignedColor != null) return assignedColor
|
||||
|
||||
if (colorsHaveBeenSet) {
|
||||
return nameColorForPosition(groupSenderColors.size)
|
||||
.also { groupSenderColors[recipient.id] = it }
|
||||
}
|
||||
|
||||
val colorInt = getIncomingBodyTextColor(context, recipient.hasWallpaper)
|
||||
return NameColor(lightColor = colorInt, darkColor = colorInt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Colorizer] implementation for CFv2 (ConversationFragment v2).
|
||||
*
|
||||
* Colors are derived from each member's sorted position in the group, populated via
|
||||
* [onGroupMembershipChanged]. For the legacy CFv1 approach, see [ColorizerV1].
|
||||
*/
|
||||
class ColorizerV2 @JvmOverloads constructor(groupMemberIds: List<ServiceId> = emptyList()) : Colorizer {
|
||||
private val groupMembers: LinkedHashSet<ServiceId> = linkedSetOf()
|
||||
|
||||
init {
|
||||
onGroupMembershipChanged(groupMemberIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the entire set of group members used for position-based name color assignment.
|
||||
*
|
||||
* Must be called before [getNameColor] to ensure colors are assigned correctly.
|
||||
*/
|
||||
fun onGroupMembershipChanged(serviceIds: List<ServiceId>) {
|
||||
groupMembers.addAll(serviceIds.sortedBy { it.toString() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [NameColor] for the given [recipient] based on their sorted position among the
|
||||
* other group members supplied via [onGroupMembershipChanged].
|
||||
*
|
||||
* Falls back to a default text color if the recipient has no service ID or is not
|
||||
* found in the current membership set.
|
||||
*/
|
||||
override fun getNameColor(context: Context, recipient: Recipient): NameColor {
|
||||
val serviceId = recipient.serviceId.orNull()
|
||||
if (serviceId != null) {
|
||||
val position = groupMembers.indexOf(serviceId)
|
||||
if (position >= 0) return nameColorForPosition(position)
|
||||
}
|
||||
|
||||
val colorInt = getIncomingBodyTextColor(context, recipient.hasWallpaper)
|
||||
return NameColor(lightColor = colorInt, darkColor = colorInt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nameColorForPosition(position: Int): NameColor {
|
||||
return ChatColorsPalette.Names.all[position % ChatColorsPalette.Names.all.size]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import org.signal.core.ui.util.ThemeUtil
|
||||
/**
|
||||
* Class which stores information for a Recipient's name color in a group.
|
||||
*/
|
||||
class NameColor(
|
||||
@ColorInt private val lightColor: Int,
|
||||
@ColorInt private val darkColor: Int
|
||||
data class NameColor(
|
||||
@get:ColorInt private val lightColor: Int,
|
||||
@get:ColorInt private val darkColor: Int
|
||||
) {
|
||||
@ColorInt
|
||||
fun getColor(context: Context): Int {
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.DeliveryStatusView
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerView
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
@@ -101,7 +102,7 @@ class ChatColorPreviewView @JvmOverloads constructor(
|
||||
wallpaper = findViewById(R.id.wallpaper)
|
||||
wallpaperDim = findViewById(R.id.wallpaper_dim)
|
||||
colorizerView = findViewById(R.id.colorizer)
|
||||
colorizer = Colorizer()
|
||||
colorizer = ColorizerV2()
|
||||
} finally {
|
||||
typedArray?.recycle()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
|
||||
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
@@ -74,7 +74,8 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
val colorizer = Colorizer()
|
||||
@Suppress("DEPRECATION")
|
||||
val colorizer = ColorizerV1()
|
||||
|
||||
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
|
||||
setCondensedMode(ConversationItemDisplayMode.Condensed(ConversationItemDisplayMode.MessageMode.STANDARD))
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
|
||||
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.quotes.OriginalMessageSeparatorDecoration
|
||||
@@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.ViewModelFactory
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -78,16 +78,20 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val callback = findListener<ConversationBottomSheetCallback>() ?: EmptyConversationBottomSheetCallback
|
||||
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
val colorizer = Colorizer()
|
||||
@Suppress("DEPRECATION")
|
||||
val colorizer = ColorizerV1()
|
||||
|
||||
val messageAdapter = ConversationAdapter(
|
||||
requireContext(),
|
||||
viewLifecycleOwner,
|
||||
Glide.with(this),
|
||||
Locale.getDefault(),
|
||||
ConversationAdapterListener(),
|
||||
ConversationAdapterListener(callback),
|
||||
conversationRecipient.hasWallpaper,
|
||||
colorizer
|
||||
).apply {
|
||||
@@ -142,7 +146,7 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
return callback
|
||||
}
|
||||
|
||||
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by requireListener<ConversationBottomSheetCallback>().getConversationAdapterListener() {
|
||||
private class ConversationAdapterListener(callback: ConversationBottomSheetCallback) : ConversationAdapter.ItemClickListener by callback.getConversationAdapterListener() {
|
||||
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
|
||||
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit
|
||||
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.ui.edit
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Observer
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItem
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
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.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
|
||||
/**
|
||||
* Empty object for when a callback can't be found.
|
||||
*/
|
||||
object EmptyConversationAdapterListener : ConversationAdapter.ItemClickListener {
|
||||
override fun onItemClick(item: MultiselectPart?) = Unit
|
||||
override fun onItemLongClick(itemView: View?, item: MultiselectPart?) = Unit
|
||||
override fun onQuoteClicked(messageRecord: MmsMessageRecord?) = Unit
|
||||
override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit
|
||||
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) = Unit
|
||||
override fun onStickerClicked(stickerLocator: StickerLocator) = Unit
|
||||
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) = Unit
|
||||
override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) = Unit
|
||||
override fun onAddToContactsClicked(contact: Contact) = Unit
|
||||
override fun onMessageSharedContactClicked(choices: List<Recipient?>) = Unit
|
||||
override fun onInviteSharedContactClicked(choices: List<Recipient?>) = Unit
|
||||
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
|
||||
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
|
||||
override fun onMessageWithErrorClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) = Unit
|
||||
override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState?>) = Unit
|
||||
override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState?>) = Unit
|
||||
override fun onVoiceNotePause(uri: Uri) = Unit
|
||||
override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) = Unit
|
||||
override fun onVoiceNoteSeekTo(uri: Uri, position: Double) = Unit
|
||||
override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) = 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 onPlayInlineContent(conversationMessage: ConversationMessage?) = Unit
|
||||
override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) = Unit
|
||||
override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) = Unit
|
||||
override fun onChangeNumberUpdateContact(recipient: Recipient) = Unit
|
||||
override fun onChangeProfileNameUpdateContact(recipient: Recipient) = Unit
|
||||
override fun onCallToAction(action: String) = Unit
|
||||
override fun onDonateClicked() = Unit
|
||||
override fun onBlockJoinRequest(recipient: Recipient) = Unit
|
||||
override fun onRecipientNameClicked(target: RecipientId) = Unit
|
||||
override fun onInviteToSignalClicked() = Unit
|
||||
override fun onActivatePaymentsClicked() = Unit
|
||||
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
|
||||
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit
|
||||
override fun onUrlClicked(url: String): Boolean = false
|
||||
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onGiftBadgeRevealed(messageRecord: MessageRecord) = Unit
|
||||
override fun goToMediaPreview(parent: ConversationItem?, sharedElement: View?, args: MediaIntentFactory.MediaPreviewArgs?) = Unit
|
||||
override fun onEditedIndicatorClicked(conversationMessage: ConversationMessage) = Unit
|
||||
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
|
||||
override fun onShowSafetyTips(forGroup: Boolean) = Unit
|
||||
override fun onReportSpamLearnMoreClicked() = Unit
|
||||
override fun onMessageRequestAcceptOptionsClicked() = Unit
|
||||
override fun onItemDoubleClick(multiselectPart: MultiselectPart?) = Unit
|
||||
override fun onPaymentTombstoneClicked() = Unit
|
||||
override fun onDisplayMediaNoLongerAvailableSheet() = Unit
|
||||
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
|
||||
override fun onUpdateSignalClicked() = Unit
|
||||
override fun onViewResultsClicked(pollId: Long) = Unit
|
||||
override fun onViewPollClicked(messageId: Long) = Unit
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
|
||||
override fun onViewPinnedMessage(messageId: Long) = Unit
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.ui.edit
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter
|
||||
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
|
||||
object EmptyConversationBottomSheetCallback : ConversationBottomSheetCallback {
|
||||
override fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener = EmptyConversationAdapterListener
|
||||
override fun jumpToMessage(messageRecord: MessageRecord) = Unit
|
||||
override fun unpin(conversationMessage: ConversationMessage) = Unit
|
||||
override fun copy(conversationMessage: ConversationMessage) = Unit
|
||||
override fun delete(conversationMessage: ConversationMessage) = Unit
|
||||
override fun save(conversationMessage: ConversationMessage) = Unit
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentResultListener
|
||||
import androidx.fragment.app.activityViewModels
|
||||
@@ -119,6 +120,7 @@ import org.signal.core.util.concurrent.addTo
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireParcelableCompat
|
||||
import org.signal.core.util.setActionItemTint
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
@@ -208,7 +210,7 @@ import org.thoughtcrime.securesms.conversation.SelectedConversationModel
|
||||
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
|
||||
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository
|
||||
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository.ShareOrDraftData
|
||||
@@ -255,6 +257,8 @@ 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.memberlabel.MemberLabelActivity
|
||||
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
|
||||
@@ -279,6 +283,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
|
||||
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
@@ -316,6 +321,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientExporter
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
|
||||
@@ -412,6 +418,7 @@ class ConversationFragment :
|
||||
private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut"
|
||||
private const val SAVED_STATE_IS_SEARCH_REQUESTED = "is_search_requested"
|
||||
private const val EMOJI_SEARCH_FRAGMENT_TAG = "EmojiSearchFragment"
|
||||
private const val MESSAGE_DETAILS_TAG = "MessageDetailsFragment"
|
||||
|
||||
private const val SCROLL_HEADER_ANIMATION_DURATION: Long = 100L
|
||||
private const val SCROLL_HEADER_CLOSE_DELAY: Long = SCROLL_HEADER_ANIMATION_DURATION * 4
|
||||
@@ -531,7 +538,7 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
private val conversationTooltips = ConversationTooltips(this)
|
||||
private val colorizer = Colorizer()
|
||||
private val colorizer = ColorizerV2()
|
||||
private val textDraftSaveDebouncer = Debouncer(500)
|
||||
private val doubleTapToEditDebouncer = DoubleClickDebouncer(200)
|
||||
private val recentEmojis: RecentEmojiPageModel by lazy { RecentEmojiPageModel(AppDependencies.application, TextSecurePreferences.RECENT_STORAGE_KEY) }
|
||||
@@ -634,8 +641,14 @@ class ConversationFragment :
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.toolbar.isBackInvokedCallbackEnabled = false
|
||||
|
||||
binding.root.setUseWindowTypes(args.conversationScreenType == ConversationScreenType.NORMAL && !resources.getWindowSizeClass().isSplitPane())
|
||||
if (args.conversationScreenType == ConversationScreenType.BUBBLE) {
|
||||
binding.root.setNavigationBarInsetOverride(0)
|
||||
view.post {
|
||||
ViewCompat.requestApplyInsets(binding.root)
|
||||
binding.root.requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
@@ -676,6 +689,16 @@ class ConversationFragment :
|
||||
|
||||
container.fragmentManager = childFragmentManager
|
||||
|
||||
childFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
|
||||
val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java)
|
||||
startActivity(MemberLabelActivity.createIntent(requireContext(), groupId))
|
||||
}
|
||||
|
||||
childFragmentManager.setFragmentResultListener(AboutSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
|
||||
val groupId = bundle.requireParcelableCompat(AboutSheet.RESULT_GROUP_ID, GroupId.V2::class.java)
|
||||
startActivity(MemberLabelActivity.createIntent(requireContext(), groupId))
|
||||
}
|
||||
|
||||
ToolbarDependentMarginListener(binding.toolbar)
|
||||
initializeMediaKeyboard()
|
||||
|
||||
@@ -780,6 +803,10 @@ class ConversationFragment :
|
||||
}
|
||||
keyboardEvents = null
|
||||
|
||||
if (!requireActivity().isChangingConfigurations) {
|
||||
(requireActivity().supportFragmentManager.findFragmentByTag(MESSAGE_DETAILS_TAG) as? DialogFragment)?.dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
super.onDestroyView()
|
||||
if (pinnedShortcutReceiver != null) {
|
||||
requireActivity().unregisterReceiver(pinnedShortcutReceiver)
|
||||
@@ -990,9 +1017,13 @@ class ConversationFragment :
|
||||
|
||||
when {
|
||||
state.isReactionDelegateShowing -> reactionDelegate.hide()
|
||||
|
||||
state.isSearchRequested -> searchMenuItem?.collapseActionView()
|
||||
|
||||
state.isInActionMode -> finishActionMode()
|
||||
|
||||
state.isMediaKeyboardShowing -> container.hideInput()
|
||||
|
||||
else -> {
|
||||
// State has changed since the back handler was enabled. Let the back press proceed
|
||||
// to the next handler by triggering onBackPressed again after setting a skip flag
|
||||
@@ -1872,13 +1903,16 @@ class ConversationFragment :
|
||||
|
||||
when (data) {
|
||||
is ShareOrDraftData.SendKeyboardImage -> sendMessageWithoutComposeInput(slide = data.slide, clearCompose = false)
|
||||
|
||||
is ShareOrDraftData.SendSticker -> sendMessageWithoutComposeInput(slide = data.slide, clearCompose = true)
|
||||
|
||||
is ShareOrDraftData.SetText -> {
|
||||
composeText.setDraftText(data.text)
|
||||
inputPanel.clickOnComposeInput()
|
||||
}
|
||||
|
||||
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(data.location, MediaConstraints.getPushMediaConstraints())
|
||||
|
||||
is ShareOrDraftData.SetEditMessage -> {
|
||||
composeText.setDraftText(data.draftText)
|
||||
inputPanel.enterEditMessageMode(Glide.with(this), data.messageEdit, true, data.clearQuote)
|
||||
@@ -2675,6 +2709,7 @@ class ConversationFragment :
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is Result.Success -> Log.d(TAG, "$logMessage complete")
|
||||
|
||||
is Result.Failure -> {
|
||||
Log.d(TAG, "$logMessage failed ${result.failure}")
|
||||
toast(GroupErrors.getUserDisplayMessage(result.failure))
|
||||
@@ -2793,7 +2828,11 @@ class ConversationFragment :
|
||||
|
||||
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
|
||||
val recipientSnapshot = viewModel.recipientSnapshot ?: return
|
||||
MessageDetailsFragment.create(conversationMessage.messageRecord, recipientSnapshot.id).show(childFragmentManager, null)
|
||||
if (requireActivity() is MainActivity) {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, conversationMessage.messageRecord.id))
|
||||
} else {
|
||||
MessageDetailsFragment.create(conversationMessage.messageRecord, recipientSnapshot.id).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
|
||||
@@ -2810,7 +2849,10 @@ class ConversationFragment :
|
||||
|
||||
disposables += DeleteDialog.show(
|
||||
context = requireContext(),
|
||||
messageRecords = records
|
||||
messageRecords = records,
|
||||
title = requireContext().resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_title, records.size, records.size),
|
||||
message = requireContext().resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_body, records.size, records.size),
|
||||
isAdmin = conversationGroupViewModel.isAdmin()
|
||||
).observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (deleted: Boolean, _: Boolean) ->
|
||||
if (!deleted) return@subscribe
|
||||
@@ -3309,8 +3351,10 @@ class ConversationFragment :
|
||||
.show(childFragmentManager)
|
||||
} else if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||
ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord)
|
||||
} else if (requireActivity() is MainActivity) {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, messageRecord.id))
|
||||
} else {
|
||||
MessageDetailsFragment.create(messageRecord, recipientId).show(childFragmentManager, null)
|
||||
MessageDetailsFragment.create(messageRecord, recipientId).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3463,14 +3507,12 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
override fun onCallToAction(action: String) {
|
||||
if ("gift_badge" == action) {
|
||||
checkoutLauncher.launch(InAppPaymentType.ONE_TIME_GIFT)
|
||||
} else if ("username_edit" == action) {
|
||||
startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
|
||||
} else if ("calls_tab" == action) {
|
||||
startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainNavigationListLocation.CALLS))
|
||||
} else if ("chat_folder" == action) {
|
||||
startActivity(AppSettingsActivity.chatFolders(requireContext()))
|
||||
when (action) {
|
||||
"gift_badge" -> checkoutLauncher.launch(InAppPaymentType.ONE_TIME_GIFT)
|
||||
"username_edit" -> startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
|
||||
"calls_tab" -> startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainNavigationListLocation.CALLS))
|
||||
"chat_folder" -> startActivity(AppSettingsActivity.chatFolders(requireContext()))
|
||||
"remote_backups" -> startActivity(AppSettingsActivity.remoteBackups(requireContext()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3971,7 +4013,7 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
override fun handleMuteNotifications() {
|
||||
MuteDialog.show(requireContext(), viewModel::muteConversation)
|
||||
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner, viewModel::muteConversation)
|
||||
}
|
||||
|
||||
override fun handleUnmuteNotifications() {
|
||||
@@ -4135,8 +4177,11 @@ class ConversationFragment :
|
||||
val slides: List<Slide> = result.nonUploadedMedia.mapNotNull {
|
||||
when {
|
||||
MediaUtil.isVideoType(it.contentType) -> VideoSlide(requireContext(), it.uri, it.size, it.isVideoGif, it.width, it.height, it.caption, it.transformProperties)
|
||||
|
||||
MediaUtil.isGif(it.contentType) -> GifSlide(requireContext(), it.uri, it.size, it.width, it.height, it.isBorderless, it.caption)
|
||||
|
||||
MediaUtil.isImageType(it.contentType) -> ImageSlide(requireContext(), it.uri, it.contentType, it.size, it.width, it.height, it.isBorderless, it.caption, null, it.transformProperties)
|
||||
|
||||
MediaUtil.isDocumentType(it.contentType) -> {
|
||||
DocumentSlide(requireContext(), it.uri, it.contentType!!, it.size, it.fileName)
|
||||
}
|
||||
@@ -4338,6 +4383,7 @@ class ConversationFragment :
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is Result.Success -> Log.d(TAG, "Cancel request complete")
|
||||
|
||||
is Result.Failure -> {
|
||||
Log.d(TAG, "Cancel join request failed ${result.failure}")
|
||||
toast(GroupErrors.getUserDisplayMessage(result.failure))
|
||||
@@ -4716,9 +4762,13 @@ class ConversationFragment :
|
||||
if (button != null) {
|
||||
when (button) {
|
||||
AttachmentKeyboardButton.GALLERY -> conversationActivityResultContracts.launchGallery(recipient.id, composeText.textTrimmed, inputPanel.quote.isPresent)
|
||||
|
||||
AttachmentKeyboardButton.CONTACT -> conversationActivityResultContracts.launchSelectContact()
|
||||
|
||||
AttachmentKeyboardButton.LOCATION -> conversationActivityResultContracts.launchSelectLocation(recipient.chatColors)
|
||||
|
||||
AttachmentKeyboardButton.PAYMENT -> AttachmentManager.selectPayment(this@ConversationFragment, recipient)
|
||||
|
||||
AttachmentKeyboardButton.FILE -> {
|
||||
if (!conversationActivityResultContracts.launchSelectFile()) {
|
||||
toast(R.string.AttachmentManager_cant_open_media_selection, Toast.LENGTH_LONG)
|
||||
|
||||
@@ -146,7 +146,7 @@ private fun CreatePollScreen(
|
||||
// Parts of poll
|
||||
var question by remember { mutableStateOf("") }
|
||||
val options = remember { mutableStateListOf("", "") }
|
||||
var allowMultiple by remember { mutableStateOf(false) }
|
||||
var allowMultiple by remember { mutableStateOf(true) }
|
||||
|
||||
var hasMinimumOptions by remember { mutableStateOf(false) }
|
||||
val isEnabled = question.isNotBlank() && hasMinimumOptions
|
||||
|
||||
@@ -62,6 +62,10 @@ class ConversationGroupViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun isAdmin(): Boolean {
|
||||
return _memberLevel.value?.groupTableMemberLevel == GroupTable.MemberLevel.ADMINISTRATOR
|
||||
}
|
||||
|
||||
fun isNonAdminInAnnouncementGroup(): Boolean {
|
||||
val memberLevel = _memberLevel.value ?: return false
|
||||
return memberLevel.groupTableMemberLevel != GroupTable.MemberLevel.ADMINISTRATOR && memberLevel.isAnnouncementGroup
|
||||
|
||||
@@ -492,7 +492,6 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
|
||||
initializeSearchListener();
|
||||
initializeFilterListener();
|
||||
itemAnimator.disable();
|
||||
SpoilerAnnotation.resetRevealedSpoilers();
|
||||
|
||||
if (mainToolbarViewModel.getState().getValue().getMode() != MainToolbarMode.SEARCH && list.getAdapter() != defaultAdapter) {
|
||||
@@ -550,7 +549,6 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
AppForegroundObserver.addListener(appForegroundObserver);
|
||||
itemAnimator.disable();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1179,9 +1177,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
}
|
||||
|
||||
private void handleMute(@NonNull Collection<Conversation> conversations) {
|
||||
MuteDialog.show(requireContext(), until -> {
|
||||
updateMute(conversations, until);
|
||||
});
|
||||
MuteDialog.show(requireContext(), getChildFragmentManager(), getViewLifecycleOwner(), until -> updateMute(conversations, until));
|
||||
}
|
||||
|
||||
private void handleUnmute(@NonNull Collection<Conversation> conversations) {
|
||||
@@ -1576,6 +1572,34 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
chatFolderList.getLayoutManager().startSmoothScroll(smoothScroller);
|
||||
}
|
||||
|
||||
// Manage change animations so we don't animate the list when switching folders
|
||||
itemAnimator.disableChangeAnimations();
|
||||
defaultAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
|
||||
@Override
|
||||
public void onChanged() {
|
||||
defaultAdapter.unregisterAdapterDataObserver(this);
|
||||
itemAnimator.enableChangeAnimations();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemRangeInserted(int positionStart, int itemCount) {
|
||||
defaultAdapter.unregisterAdapterDataObserver(this);
|
||||
itemAnimator.enableChangeAnimations();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemRangeChanged(int positionStart, int itemCount) {
|
||||
defaultAdapter.unregisterAdapterDataObserver(this);
|
||||
itemAnimator.enableChangeAnimations();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemRangeRemoved(int positionStart, int itemCount) {
|
||||
defaultAdapter.unregisterAdapterDataObserver(this);
|
||||
itemAnimator.enableChangeAnimations();
|
||||
}
|
||||
});
|
||||
|
||||
viewModel.select(chatFolder);
|
||||
}
|
||||
|
||||
@@ -1586,7 +1610,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
|
||||
@Override
|
||||
public void onMuteAll(@NonNull ChatFolderRecord chatFolder) {
|
||||
MuteDialog.show(requireContext(), until -> viewModel.onUpdateMute(chatFolder, until));
|
||||
MuteDialog.show(requireContext(), getChildFragmentManager(), getViewLifecycleOwner(), until -> viewModel.onUpdateMute(chatFolder, until));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1919,6 +1943,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
|
||||
void onMultiSelectFinished();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user