mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-03 17:03:04 +01:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8737995fa | ||
|
|
1bbefea857 | ||
|
|
143630c41b | ||
|
|
577eaa1eae | ||
|
|
316b071c81 | ||
|
|
5a6f55c0a8 | ||
|
|
e008a50acc | ||
|
|
41c3913482 | ||
|
|
803ff76678 | ||
|
|
309081437a | ||
|
|
5f152b73c2 | ||
|
|
f8d3336a1e | ||
|
|
dc1fdffe6a | ||
|
|
622d9c909f | ||
|
|
4e3ef19c1f | ||
|
|
b054a30fa7 | ||
|
|
7266c24354 | ||
|
|
5ec2877bcc | ||
|
|
0d93446c7d | ||
|
|
1e395ab416 | ||
|
|
0acb5ac7cd | ||
|
|
3b18b5d2b7 | ||
|
|
16e63a061d | ||
|
|
a6c8b940c9 | ||
|
|
74d9e3248b | ||
|
|
3af8b6050c | ||
|
|
da966753a1 | ||
|
|
0ad4b3f73e | ||
|
|
e8d072d4be | ||
|
|
b0eed4a095 | ||
|
|
ba720efe61 | ||
|
|
e23d575460 | ||
|
|
7fbcd17759 | ||
|
|
a95ebb2158 | ||
|
|
8a36425cac | ||
|
|
4261ed39dc | ||
|
|
ca37a884fd | ||
|
|
9fbb7683bc | ||
|
|
42e275ef0a | ||
|
|
19ece12e93 | ||
|
|
3ef0d3e4a3 | ||
|
|
602ea46b8b | ||
|
|
95c0bc6052 | ||
|
|
bd4ce1788c | ||
|
|
20d16a8433 | ||
|
|
db4c11cd53 | ||
|
|
f439e1f8e3 | ||
|
|
080b1aab83 | ||
|
|
61ba2ac97a | ||
|
|
7eebb38eda | ||
|
|
43e7d65af5 | ||
|
|
386d8bb312 | ||
|
|
3fbd72092c | ||
|
|
4e5b15cd88 | ||
|
|
8b2aeba3bd | ||
|
|
1d2334b920 | ||
|
|
38a234ae66 | ||
|
|
2c1226dc02 | ||
|
|
1df8ef6464 | ||
|
|
f8d40bf86d | ||
|
|
58ab03b4e3 | ||
|
|
0bf54e6b45 | ||
|
|
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 |
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 = 1656
|
||||
val canonicalVersionName = "8.0.4"
|
||||
val canonicalVersionCode = 1663
|
||||
val canonicalVersionName = "8.2.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -49,8 +53,6 @@ val localProperties: Properties? = if (localPropertiesFile.exists()) {
|
||||
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
|
||||
|
||||
val selectableVariants = listOf(
|
||||
"nightlyBackupRelease",
|
||||
"nightlyBackupSpinner",
|
||||
"nightlyProdSpinner",
|
||||
"nightlyProdPerf",
|
||||
"nightlyProdRelease",
|
||||
@@ -464,17 +466,6 @@ android {
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
}
|
||||
|
||||
create("backup") {
|
||||
initWith(getByName("staging"))
|
||||
|
||||
dimension = "environment"
|
||||
|
||||
applicationIdSuffix = ".backup"
|
||||
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -516,7 +507,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 +558,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 +798,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,9 +50,18 @@ 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()
|
||||
BenchmarkWebSocketConnection.startWholeBatchTrace()
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
}
|
||||
"delete-thread" -> {
|
||||
val pendingResult = goAsync()
|
||||
Thread {
|
||||
handleDeleteThread()
|
||||
pendingResult.finish()
|
||||
}.start()
|
||||
}
|
||||
else -> Log.w(TAG, "Unknown command: $command")
|
||||
}
|
||||
@@ -61,25 +75,23 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
BenchmarkWebSocketConnection.authInstance.run {
|
||||
Log.i(TAG, "Sending initial message form Bob to establish session.")
|
||||
addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
|
||||
releaseMessages()
|
||||
Log.i(TAG, "Sending initial message form Bob to establish session.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
// Sleep briefly to let the message be processed.
|
||||
ThreadUtil.sleep(100)
|
||||
}
|
||||
// Sleep briefly to let the message be processed.
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Have Bob generate N messages that will be received by Alice
|
||||
val messageCount = 100
|
||||
val messageCount = 500
|
||||
val envelopes = client.generateInboundEnvelopes(messageCount)
|
||||
|
||||
val messages = envelopes.map { e -> e.toWebSocketPayload() }
|
||||
|
||||
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
|
||||
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
|
||||
BenchmarkWebSocketConnection.addPendingMessages(messages)
|
||||
BenchmarkWebSocketConnection.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun handlePrepareGroupSend() {
|
||||
@@ -90,27 +102,92 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
BenchmarkWebSocketConnection.authInstance.run {
|
||||
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
|
||||
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
releaseMessages()
|
||||
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
// Sleep briefly to let the messages be processed.
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
// Sleep briefly to let the messages be processed.
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Have clients generate N group messages that will be received by Alice
|
||||
clients.forEach { client ->
|
||||
val allClientMessages = clients.map { client ->
|
||||
val messageCount = 100
|
||||
val envelopes = client.generateInboundGroupEnvelopes(messageCount, Harness.groupMasterKey)
|
||||
|
||||
val messages = envelopes.map { e -> e.toWebSocketPayload() }
|
||||
|
||||
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
|
||||
envelopes.map { e -> e.toWebSocketPayload() }
|
||||
}
|
||||
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
|
||||
|
||||
BenchmarkWebSocketConnection.addPendingMessages(interleave(allClientMessages))
|
||||
BenchmarkWebSocketConnection.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.addPendingMessages(interleave(allClientEnvelopes))
|
||||
BenchmarkWebSocketConnection.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun establishGroupSessions(clients: List<OtherClient>) {
|
||||
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
|
||||
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteThread() {
|
||||
val threadId = SignalDatabase.threads.getRecentConversationList(1, false, false).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow("_id"))
|
||||
} else {
|
||||
Log.w(TAG, "No active threads found for deletion benchmark")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Deleting thread $threadId")
|
||||
SignalDatabase.threads.deleteConversation(threadId, syncThreadDelete = false)
|
||||
Log.i(TAG, "Thread $threadId deleted")
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
package org.signal.benchmark
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.TextView
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
when (intent.extras!!.getString("setup-type")) {
|
||||
"cold-start" -> setupColdStart()
|
||||
"conversation-open" -> setupConversationOpen()
|
||||
"message-send" -> setupMessageSend()
|
||||
"group-message-send" -> setupGroupMessageSend()
|
||||
var setupComplete by mutableStateOf(false)
|
||||
|
||||
setContent {
|
||||
if (setupComplete) {
|
||||
Text("done")
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
val textView: TextView = TextView(this).apply {
|
||||
text = "done"
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
when (intent.extras!!.getString("setup-type")) {
|
||||
"cold-start" -> setupColdStart()
|
||||
"conversation-open" -> setupConversationOpen()
|
||||
"message-send" -> setupMessageSend()
|
||||
"group-message-send" -> setupGroupMessageSend()
|
||||
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
|
||||
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
|
||||
"thread-delete" -> setupThreadDelete()
|
||||
"thread-delete-group" -> setupThreadDeleteGroup()
|
||||
}
|
||||
setupComplete = true
|
||||
}
|
||||
setContentView(textView)
|
||||
}
|
||||
|
||||
private fun setupColdStart() {
|
||||
@@ -68,4 +91,99 @@ class BenchmarkSetupActivity : BaseActivity() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupGroup()
|
||||
}
|
||||
|
||||
private fun setupThreadDelete() {
|
||||
TestUsers.setupSelf()
|
||||
val recipientIds = TestUsers.setupTestRecipients(2)
|
||||
val recipient = Recipient.resolved(recipientIds[0])
|
||||
val reactionAuthor = recipientIds[1]
|
||||
val messagesToAdd = 20_000
|
||||
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
|
||||
|
||||
for (i in 0 until messagesToAdd) {
|
||||
val timestamp = generator.nextTimestamp()
|
||||
when {
|
||||
i % 20 == 0 -> TestMessages.insertIncomingVoiceMessage(other = recipient, timestamp = timestamp)
|
||||
i % 4 == 0 -> TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 1, timestamp = timestamp)
|
||||
else -> TestMessages.insertIncomingTextMessage(other = recipient, body = "Message $i", timestamp = timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient)
|
||||
TestDbUtils.insertReactionsForThread(threadId, reactionAuthor, moduloFilter = 5)
|
||||
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
}
|
||||
|
||||
private fun setupThreadDeleteGroup() {
|
||||
TestUsers.setupSelf()
|
||||
val groupId = TestUsers.setupGroup()
|
||||
val groupRecipient = Recipient.externalGroupExact(groupId)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val selfId = Recipient.self().id
|
||||
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
|
||||
|
||||
val messagesToAdd = 20_000
|
||||
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
|
||||
|
||||
for (i in 0 until messagesToAdd) {
|
||||
val timestamp = generator.nextTimestamp()
|
||||
when {
|
||||
i % 4 == 0 -> TestMessages.insertOutgoingImageMessage(other = groupRecipient, attachmentCount = 1, timestamp = timestamp)
|
||||
else -> {
|
||||
val message = OutgoingMessage(
|
||||
recipient = groupRecipient,
|
||||
body = "Message $i",
|
||||
timestamp = timestamp,
|
||||
isSecure = true
|
||||
)
|
||||
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
|
||||
SignalDatabase.messages.markAsSent(insert.messageId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestDbUtils.insertGroupReceiptsForThread(threadId, memberRecipientIds)
|
||||
TestDbUtils.insertReactionsForThread(threadId, memberRecipientIds[0], moduloFilter = 5)
|
||||
TestDbUtils.insertMentionsForThread(threadId, memberRecipientIds[0], moduloFilter = 10)
|
||||
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
}
|
||||
|
||||
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,114 @@ object TestDbUtils {
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
|
||||
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-inserts a reaction on every Nth message (by _id modulo) in the given thread.
|
||||
*/
|
||||
fun insertReactionsForThread(threadId: Long, authorId: RecipientId, moduloFilter: Int) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO reaction (message_id, author_id, emoji, date_sent, date_received)
|
||||
SELECT ${MessageTable.ID}, ?, '👍', ${MessageTable.DATE_SENT}, ${MessageTable.DATE_RECEIVED}
|
||||
FROM ${MessageTable.TABLE_NAME}
|
||||
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
|
||||
""".trimIndent(),
|
||||
arrayOf(authorId.toLong().toString(), threadId.toString(), moduloFilter.toString())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-inserts group receipt rows for every message in the given thread, one row per member.
|
||||
*/
|
||||
fun insertGroupReceiptsForThread(threadId: Long, memberRecipientIds: List<RecipientId>) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (recipientId in memberRecipientIds) {
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO group_receipts (mms_id, address, status, timestamp)
|
||||
SELECT ${MessageTable.ID}, ?, 2, ${MessageTable.DATE_SENT}
|
||||
FROM ${MessageTable.TABLE_NAME}
|
||||
WHERE ${MessageTable.THREAD_ID} = ?
|
||||
""".trimIndent(),
|
||||
arrayOf(recipientId.toLong().toString(), threadId.toString())
|
||||
)
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-inserts a mention on every Nth message (by _id modulo) in the given thread.
|
||||
*/
|
||||
fun insertMentionsForThread(threadId: Long, mentionedRecipientId: RecipientId, moduloFilter: Int) {
|
||||
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO mention (thread_id, message_id, recipient_id, range_start, range_length)
|
||||
SELECT ${MessageTable.THREAD_ID}, ${MessageTable.ID}, ?, 0, 5
|
||||
FROM ${MessageTable.TABLE_NAME}
|
||||
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
|
||||
""".trimIndent(),
|
||||
arrayOf(mentionedRecipientId.toLong().toString(), threadId.toString(), moduloFilter.toString())
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,23 +29,42 @@ import java.util.concurrent.TimeoutException
|
||||
class BenchmarkWebSocketConnection : WebSocketConnection {
|
||||
|
||||
companion object {
|
||||
lateinit var authInstance: BenchmarkWebSocketConnection
|
||||
private set
|
||||
private val authInstances = mutableListOf<BenchmarkWebSocketConnection>()
|
||||
private val unauthInstances = mutableListOf<BenchmarkWebSocketConnection>()
|
||||
|
||||
@Synchronized
|
||||
fun createAuthInstance(): WebSocketConnection {
|
||||
authInstance = BenchmarkWebSocketConnection()
|
||||
val authInstance = BenchmarkWebSocketConnection()
|
||||
authInstances += authInstance
|
||||
return authInstance
|
||||
}
|
||||
|
||||
lateinit var unauthInstance: BenchmarkWebSocketConnection
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
fun createUnauthInstance(): WebSocketConnection {
|
||||
unauthInstance = BenchmarkWebSocketConnection()
|
||||
val unauthInstance = BenchmarkWebSocketConnection()
|
||||
unauthInstances += unauthInstance
|
||||
return unauthInstance
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startWholeBatchTrace() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.startWholeBatchTrace = true }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun releaseMessages() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.releaseMessages() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addPendingMessages(messages) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addQueueEmptyMessage() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
|
||||
}
|
||||
}
|
||||
|
||||
override val name: String = "bench-${System.identityHashCode(this)}"
|
||||
@@ -58,7 +77,8 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
|
||||
var startWholeBatchTrace = false
|
||||
|
||||
@Volatile
|
||||
private var isShutdown = false
|
||||
var isShutdown = false
|
||||
private set
|
||||
|
||||
override fun connect(): Observable<WebSocketConnectionState> {
|
||||
state.onNext(WebSocketConnectionState.CONNECTED)
|
||||
|
||||
@@ -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
@@ -1,59 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup;
|
||||
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.ui.bottomsheet.RecipientBottomSheetDialogFragment;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class GroupMembersDialog {
|
||||
|
||||
private final FragmentActivity fragmentActivity;
|
||||
private final Recipient groupRecipient;
|
||||
|
||||
public GroupMembersDialog(@NonNull FragmentActivity activity,
|
||||
@NonNull Recipient groupRecipient)
|
||||
{
|
||||
this.fragmentActivity = activity;
|
||||
this.groupRecipient = groupRecipient;
|
||||
}
|
||||
|
||||
public void display() {
|
||||
AlertDialog dialog = new MaterialAlertDialogBuilder(fragmentActivity)
|
||||
.setTitle(R.string.ConversationActivity_group_members)
|
||||
.setIcon(R.drawable.ic_group_24)
|
||||
.setCancelable(true)
|
||||
.setView(R.layout.dialog_group_members)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
|
||||
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
|
||||
memberListView.initializeAdapter(fragmentActivity);
|
||||
|
||||
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
|
||||
LiveData<List<GroupMemberEntry.FullMember>> fullMembers = liveGroup.getFullMembers();
|
||||
|
||||
//noinspection ConstantConditions
|
||||
fullMembers.observe(fragmentActivity, memberListView::setMembers);
|
||||
|
||||
dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity));
|
||||
|
||||
memberListView.setRecipientClickListener(recipient -> {
|
||||
dialog.dismiss();
|
||||
contactClick(recipient);
|
||||
});
|
||||
}
|
||||
|
||||
private void contactClick(@NonNull Recipient recipient) {
|
||||
RecipientBottomSheetDialogFragment.show(fragmentActivity.getSupportFragmentManager(), recipient.getId(), groupRecipient.requireGroupId());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -155,6 +155,10 @@ object ExportSkips {
|
||||
return log(sentTimestamp, "An incoming message author did not have an aci or e164.")
|
||||
}
|
||||
|
||||
fun directionlessMessageAuthorDoesNotHaveAciOrE164(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "A directionlessmessage author did not have an aci or e164.")
|
||||
}
|
||||
|
||||
fun outgoingMessageToReleaseNotesChat(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "An outgoing message was sent to the release notes chat.")
|
||||
}
|
||||
@@ -214,6 +218,14 @@ 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 quoteAuthorHasNoAciOrE164(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote author has neither an ACI nor an E164. Removing the quote.")
|
||||
}
|
||||
|
||||
fun emptyQuote(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
|
||||
}
|
||||
@@ -283,6 +295,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")
|
||||
|
||||
@@ -62,7 +62,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
|
||||
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
|
||||
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
|
||||
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
|
||||
style = ChatStyleConverter.constructRemoteChatStyle(
|
||||
db = db,
|
||||
chatColors = chatColors,
|
||||
|
||||
@@ -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,16 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
|
||||
return null
|
||||
}
|
||||
|
||||
if (!exportState.recipientIds.contains(this.quoteAuthor)) {
|
||||
Log.w(TAG, ExportOddities.quoteAuthorNotFound(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
if (exportState.recipientIdToAci[this.quoteAuthor] == null && exportState.recipientIdToE164[this.quoteAuthor] == null) {
|
||||
Log.w(TAG, ExportOddities.quoteAuthorHasNoAciOrE164(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
val localType = QuoteModel.Type.fromCode(this.quoteType)
|
||||
val remoteType = when (localType) {
|
||||
QuoteModel.Type.NORMAL -> {
|
||||
@@ -1360,11 +1375,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1377,16 +1393,16 @@ private fun ByteArray.toRemoteBodyRanges(dateSent: Long): List<BackupBodyRange>
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return decoded.ranges.map { range ->
|
||||
return decoded.ranges.mapNotNull { range ->
|
||||
val mention = range.mentionUuid?.let { UuidUtil.parseOrNull(it) }?.toByteArray()?.toByteString()?.takeIf { it.isNotEmpty() }
|
||||
val style = if (mention == null) {
|
||||
range.style?.toRemote() ?: BackupBodyRange.Style.NONE
|
||||
range.style?.toRemote()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (mention == null && style == null) {
|
||||
return emptyList()
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
BackupBodyRange(
|
||||
@@ -1662,7 +1678,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
|
||||
@@ -1688,6 +1705,11 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.directionless != null && this.authorId != selfRecipientId.toLong() && exportState.recipientIdToAci[this.authorId] == null && exportState.recipientIdToE164[this.authorId] == null) {
|
||||
Log.w(TAG, ExportSkips.directionlessMessageAuthorDoesNotHaveAciOrE164(this.dateSent))
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.outgoing != null && exportState.releaseNoteRecipientId != null && exportState.threadIdToRecipientId[this.chatId] == exportState.releaseNoteRecipientId) {
|
||||
Log.w(TAG, ExportSkips.outgoingMessageToReleaseNotesChat(this.dateSent))
|
||||
return null
|
||||
@@ -1805,7 +1827,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 +1851,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 +1869,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 +1893,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
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ object ChatArchiveImporter {
|
||||
.update(
|
||||
RecipientTable.TABLE_NAME,
|
||||
contentValuesOf(
|
||||
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
|
||||
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id else RecipientTable.NotificationSetting.ALWAYS_NOTIFY.id),
|
||||
RecipientTable.MUTE_UNTIL to (chat.muteUntilMs ?: 0),
|
||||
RecipientTable.MESSAGE_EXPIRATION_TIME to (chat.expirationTimerMs?.milliseconds?.inWholeSeconds ?: 0),
|
||||
RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION to chat.expireTimerVersion,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -125,7 +125,7 @@ object AccountDataArchiveProcessor {
|
||||
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
|
||||
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
|
||||
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
|
||||
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage,
|
||||
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
|
||||
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
|
||||
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
|
||||
autoDownloadSettings = AccountData.AutoDownloadSettings(
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.signal.core.ui.compose.Rows
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Bottom sheet shown after a successful paid backup subscription from a storage upsell megaphone.
|
||||
* Allows the user to start their first backup and optionally enable storage optimization.
|
||||
*/
|
||||
class BackupSetupCompleteBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.75f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
SetupCompleteSheetContent(
|
||||
onBackUpNowClick = { optimizeStorage ->
|
||||
SignalStore.backup.optimizeStorage = optimizeStorage
|
||||
BackupMessagesJob.enqueue()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupCompleteSheetContent(
|
||||
onBackUpNowClick: (optimizeStorage: Boolean) -> Unit
|
||||
) {
|
||||
var optimizeStorage by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups_subscribed),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.padding(2.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupSetupCompleteBottomSheet__title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupSetupCompleteBottomSheet__body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = optimizeStorage,
|
||||
text = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_storage),
|
||||
label = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_subtitle),
|
||||
onCheckChanged = { optimizeStorage = it },
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onBackUpNowClick(optimizeStorage) },
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 56.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.BackupSetupCompleteBottomSheet__back_up_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupSetupCompleteBottomSheetPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
SetupCompleteSheetContent(
|
||||
onBackUpNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.gibiBytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Bottom sheet that upsells paid backup plans to users.
|
||||
*/
|
||||
class BackupUpsellBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_SHOW_POST_PAYMENT = "show_post_payment"
|
||||
|
||||
@JvmStatic
|
||||
fun create(showPostPaymentSheet: Boolean): DialogFragment {
|
||||
return BackupUpsellBottomSheet().apply {
|
||||
arguments = bundleOf(ARG_SHOW_POST_PAYMENT to showPostPaymentSheet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val showPostPaymentSheet: Boolean by lazy(LazyThreadSafetyMode.NONE) {
|
||||
requireArguments().getBoolean(ARG_SHOW_POST_PAYMENT, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
if (showPostPaymentSheet) {
|
||||
parentFragmentManager.setFragmentResultListener(RESULT_KEY, requireActivity()) { _, bundle ->
|
||||
if (bundle.getBoolean(RESULT_KEY, false)) {
|
||||
BackupSetupCompleteBottomSheet().show(parentFragmentManager, "backup_setup_complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun UpgradeSheetContent(
|
||||
paidBackupType: MessageBackupsType.Paid,
|
||||
freeBackupType: MessageBackupsType.Free,
|
||||
isSubscribeEnabled: Boolean,
|
||||
onSubscribeClick: () -> Unit
|
||||
) {
|
||||
UpsellSheetContent(
|
||||
paidBackupType = paidBackupType,
|
||||
isSubscribeEnabled = isSubscribeEnabled,
|
||||
onSubscribeClick = onSubscribeClick,
|
||||
onNoThanksClick = { dismissAllowingStateLoss() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpsellSheetContent(
|
||||
paidBackupType: MessageBackupsType.Paid,
|
||||
isSubscribeEnabled: Boolean,
|
||||
onSubscribeClick: () -> Unit,
|
||||
onNoThanksClick: () -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val pricePerMonth = remember(paidBackupType) {
|
||||
FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.padding(2.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
FeatureCard(pricePerMonth = pricePerMonth)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onSubscribeClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.BackupUpsellBottomSheet__subscribe_for, pricePerMonth))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onNoThanksClick,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.BackupUpsellBottomSheet__no_thanks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureCard(pricePerMonth: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__price_per_month, pricePerMonth),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.BackupUpsellBottomSheet__text_and_all_media),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__full_text_media_backup))
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__storage_100gb))
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__save_on_device_storage))
|
||||
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__thanks_for_supporting))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureBullet(text: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BackupUpsellBottomSheetPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
UpsellSheetContent(
|
||||
paidBackupType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal("1.99"), Currency.getInstance("USD")),
|
||||
mediaTtl = 30.days,
|
||||
storageAllowanceBytes = 100.gibiBytes.inWholeBytes
|
||||
),
|
||||
isSubscribeEnabled = true,
|
||||
onSubscribeClick = {},
|
||||
onNoThanksClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -291,7 +291,11 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
dateView.setText(null);
|
||||
} else if (messageRecord.isFailed()) {
|
||||
int errorMsg;
|
||||
if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||
if (messageRecord.isFailedAdminDelete() && messageRecord.isIdentityMismatchFailure()) {
|
||||
errorMsg = R.string.ConversationItem_error_partially_not_deleted;
|
||||
} else if (messageRecord.isFailedAdminDelete()) {
|
||||
errorMsg = R.string.ConversationItem_error_delete_failed;
|
||||
} else if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||
errorMsg = R.string.ConversationItem_error_network_not_delivered;
|
||||
} else if (messageRecord.getToRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) {
|
||||
errorMsg = R.string.ConversationItem_error_partially_not_delivered;
|
||||
@@ -397,7 +401,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
}
|
||||
|
||||
if (onlyShowSendingStatus) {
|
||||
if (messageRecord.isOutgoing() && messageRecord.isPending()) {
|
||||
if (messageRecord.isPending()) {
|
||||
deliveryStatusView.setPending();
|
||||
} else {
|
||||
deliveryStatusView.setNone();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -135,7 +135,17 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
|
||||
}
|
||||
|
||||
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
|
||||
if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
|
||||
textDirection = TextDirectionHeuristics.FIRSTSTRONG_RTL;
|
||||
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
|
||||
setTextDirection(TEXT_DIRECTION_FIRST_STRONG_RTL);
|
||||
}
|
||||
} else {
|
||||
textDirection = TextDirectionHeuristics.ANYRTL_LTR;
|
||||
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
|
||||
setTextDirection(TEXT_DIRECTION_ANY_RTL);
|
||||
}
|
||||
}
|
||||
|
||||
setEmojiCompatEnabled(useSystemEmoji());
|
||||
}
|
||||
@@ -264,6 +274,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 +602,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 {
|
||||
|
||||
@@ -166,8 +166,7 @@ class BackupStateObserver(
|
||||
}
|
||||
|
||||
val price = latestPayment.data.amount!!.toFiatMoney()
|
||||
val isKeepAlive = latestPayment.data.redemption?.keepAlive == true
|
||||
val isPending = latestPayment.state == InAppPaymentTable.State.PENDING && !isKeepAlive
|
||||
val isPending = SignalDatabase.inAppPayments.hasPendingBackupRedemption()
|
||||
if (isPending) {
|
||||
Log.d(TAG, "[getDatabaseBackupState] We have a pending subscription.")
|
||||
return BackupState.Pending(price = price)
|
||||
@@ -243,8 +242,7 @@ class BackupStateObserver(
|
||||
* Utilizes everything we can to resolve the most accurate backup state available, including database and network.
|
||||
*/
|
||||
private suspend fun getNetworkBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
|
||||
val isKeepAlive = lastPurchase?.data?.redemption?.keepAlive == true
|
||||
if (lastPurchase?.state == InAppPaymentTable.State.PENDING && !isKeepAlive) {
|
||||
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
|
||||
Log.d(TAG, "[getNetworkBackupState] We have a pending subscription.")
|
||||
return BackupState.Pending(
|
||||
price = lastPurchase.data.amount!!.toFiatMoney()
|
||||
|
||||
@@ -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,19 +4,19 @@
|
||||
*/
|
||||
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
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
@@ -31,6 +31,9 @@ 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.Dialogs
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
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.ui.subscription.MessageBackupsKeyEducationScreen
|
||||
@@ -39,6 +42,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private val TAG = Log.tag(LocalBackupsFragment::class)
|
||||
|
||||
@@ -127,6 +131,7 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
val backupKeyUpdatedMessage = stringResource(R.string.OnDeviceBackupsFragment__backup_key_updated)
|
||||
var upgradeInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
MessageBackupsKeyVerifyScreen(
|
||||
backupKey = state.accountEntropyPool.displayValue,
|
||||
@@ -139,7 +144,9 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
backstack.removeAll { it != LocalBackupsNavKey.SETTINGS }
|
||||
|
||||
scope.launch {
|
||||
upgradeInProgress = true
|
||||
viewModel.handleUpgrade(requireContext())
|
||||
upgradeInProgress = false
|
||||
|
||||
snackbarHostState.showSnackbar(
|
||||
message = backupKeyUpdatedMessage
|
||||
@@ -147,6 +154,12 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Dialogs.IndeterminateProgressDialog(
|
||||
visible = upgradeInProgress,
|
||||
delayDuration = 100.milliseconds,
|
||||
minimumDisplayDuration = 500.milliseconds
|
||||
)
|
||||
}
|
||||
|
||||
else -> error("Unknown key: $key")
|
||||
@@ -158,18 +171,17 @@ 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)
|
||||
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
|
||||
backStack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY)
|
||||
|
||||
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, uri), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, StorageUtil.getDisplayPath(context, uri)), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Log.w(TAG, "Unified backup location selection cancelled or failed")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -112,6 +98,7 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
override fun onCreateBackupClick() {
|
||||
if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
|
||||
Log.i(TAG, "Queueing backup...")
|
||||
viewModel.onBackupStarted()
|
||||
enqueueArchive(false)
|
||||
} else {
|
||||
Permissions.with(fragment)
|
||||
@@ -119,6 +106,7 @@ class DefaultLocalBackupsSettingsCallback(
|
||||
.ifNecessary()
|
||||
.onAllGranted {
|
||||
Log.i(TAG, "Queuing backup...")
|
||||
viewModel.onBackupStarted()
|
||||
enqueueArchive(false)
|
||||
}
|
||||
.withPermanentDenialDialog(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups.local
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +17,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||
@@ -27,7 +29,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
|
||||
@@ -51,7 +52,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
private val internalSettingsState = MutableStateFlow(
|
||||
LocalBackupsSettingsState(
|
||||
backupsEnabled = SignalStore.backup.newLocalBackupsEnabled,
|
||||
folderDisplayName = SignalStore.backup.newLocalBackupsDirectory
|
||||
folderDisplayName = getDisplayName(AppDependencies.application, SignalStore.backup.newLocalBackupsDirectory)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -71,7 +72,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalBackupsDirectoryFlow.collect { directory ->
|
||||
internalSettingsState.update { it.copy(folderDisplayName = directory) }
|
||||
internalSettingsState.update { it.copy(folderDisplayName = getDisplayName(applicationContext, directory)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +97,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)) {
|
||||
@@ -116,6 +116,19 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackupStarted() {
|
||||
val context = AppDependencies.application
|
||||
internalSettingsState.update {
|
||||
it.copy(
|
||||
progress = BackupProgressState.InProgress(
|
||||
summary = context.getString(R.string.BackupsPreferenceFragment__in_progress),
|
||||
percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, 0),
|
||||
progressFraction = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||
fun onBackupEvent(event: LocalBackupV2Event) {
|
||||
val context = AppDependencies.application
|
||||
@@ -155,13 +168,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
withContext(Dispatchers.IO) {
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
|
||||
AppDependencies.jobManager.flush()
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
|
||||
|
||||
BackupPassphrase.set(context, null)
|
||||
SignalStore.settings.isBackupEnabled = false
|
||||
BackupUtil.deleteAllBackups()
|
||||
}
|
||||
|
||||
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
|
||||
|
||||
BackupPassphrase.set(context, null)
|
||||
SignalStore.settings.isBackupEnabled = false
|
||||
BackupUtil.deleteAllBackups()
|
||||
}
|
||||
|
||||
SignalStore.backup.newLocalBackupsEnabled = true
|
||||
@@ -169,6 +182,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayName(context: Context, directoryUri: String?): String? {
|
||||
if (directoryUri == null) {
|
||||
return null
|
||||
}
|
||||
return StorageUtil.getDisplayPath(context, Uri.parse(directoryUri))
|
||||
}
|
||||
|
||||
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
|
||||
return if (lastBackupTimestamp > 0) {
|
||||
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
|
||||
@@ -190,6 +190,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
promptUserForSentTimestamp()
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Disable internal user flag"),
|
||||
summary = DSLSettingsText.from("Experience life as a non-internal user. Force-stop the app to be an internal user again."),
|
||||
isChecked = state.disableInternalUser,
|
||||
onClick = {
|
||||
viewModel.setDisableInternalUser(!state.disableInternalUser)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("App UI"))
|
||||
@@ -798,6 +808,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))
|
||||
|
||||
@@ -31,5 +31,6 @@ data class InternalSettingsState(
|
||||
val hasPendingOneTimeDonation: Boolean,
|
||||
val hevcEncoding: Boolean,
|
||||
val forceSplitPane: Boolean,
|
||||
val useNewMediaActivity: Boolean
|
||||
val useNewMediaActivity: Boolean,
|
||||
val disableInternalUser: Boolean
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.keyvalue.InternalValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class InternalSettingsViewModel(private val repository: InternalSettingsRepository) : ViewModel() {
|
||||
@@ -154,8 +155,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
repository.addSampleReleaseNote()
|
||||
fun addSampleReleaseNote(callToAction: String = "action") {
|
||||
repository.addSampleReleaseNote(callToAction)
|
||||
}
|
||||
|
||||
fun addRemoteDonateMegaphone() {
|
||||
@@ -202,7 +203,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
|
||||
hevcEncoding = SignalStore.internal.hevcEncoding,
|
||||
forceSplitPane = SignalStore.internal.forceSplitPane,
|
||||
useNewMediaActivity = SignalStore.internal.useNewMediaActivity
|
||||
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
|
||||
disableInternalUser = RemoteConfig.internalUserDisabled
|
||||
)
|
||||
|
||||
fun onClearOnboardingState() {
|
||||
@@ -213,6 +215,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
StoryOnboardingDownloadJob.enqueueIfNeeded()
|
||||
}
|
||||
|
||||
fun setDisableInternalUser(disabled: Boolean) {
|
||||
RemoteConfig.internalUserDisabled = disabled
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setForceSplitPane(forceSplitPane: Boolean) {
|
||||
SignalStore.internal.forceSplitPane = forceSplitPane
|
||||
refresh()
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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()))
|
||||
@@ -578,11 +592,19 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
if (!state.recipient.isSelf) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
|
||||
title = if (RemoteConfig.internalUser) {
|
||||
DSLSettingsText.from("${getString(R.string.ConversationSettingsFragment__sounds_and_notifications)} (Internal Only)")
|
||||
} else {
|
||||
DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications)
|
||||
},
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
|
||||
val action = if (RemoteConfig.internalUser) {
|
||||
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(state.recipient.id)
|
||||
} else {
|
||||
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
|
||||
}
|
||||
|
||||
navController.safeNavigate(action)
|
||||
}
|
||||
@@ -739,7 +761,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = group,
|
||||
onClick = {
|
||||
onRowClick = {
|
||||
CommunicationActions.startConversation(requireActivity(), group, null)
|
||||
requireActivity().finish()
|
||||
}
|
||||
@@ -787,13 +809,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)
|
||||
}
|
||||
)
|
||||
@@ -829,7 +864,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
|
||||
isEnabled = !state.isDeprecatedOrUnregistered,
|
||||
isEnabled = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
|
||||
navController.safeNavigate(action)
|
||||
@@ -1013,7 +1048,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) {
|
||||
GroupInviteSentDialog.showInvitesSent(requireContext(), viewLifecycleOwner, showGroupInvitesSentDialog.invitesSentTo)
|
||||
if (showGroupInvitesSentDialog.invitesSentTo.isNotEmpty()) {
|
||||
GroupInviteSentDialog.show(childFragmentManager, showGroupInvitesSentDialog.invitesSentTo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) {
|
||||
|
||||
@@ -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(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
|
||||
val canSetLabel = MemberLabelRepository.instance.canSetLabel(groupId, 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,38 @@ 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 var memberLabel: StyledMemberLabel? = null
|
||||
|
||||
private val recipientObserver = Observer<Recipient> { recipient ->
|
||||
onRecipientChanged(recipient)
|
||||
onRecipientChanged(recipient = recipient, memberLabel = memberLabel, 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
|
||||
memberLabel = model.memberLabel
|
||||
|
||||
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 +97,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 +115,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 +133,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)
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
|
||||
|
||||
/**
|
||||
* Represents all user-driven actions that can occur on the Sounds & Notifications settings screen.
|
||||
*/
|
||||
sealed interface SoundsAndNotificationsEvent {
|
||||
|
||||
/**
|
||||
* Mutes notifications for this recipient until the given epoch-millisecond timestamp.
|
||||
*
|
||||
* @param muteUntil Epoch-millisecond timestamp after which notifications should resume.
|
||||
* Use [Long.MAX_VALUE] to mute indefinitely.
|
||||
*/
|
||||
data class SetMuteUntil(val muteUntil: Long) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Clears any active mute, immediately restoring notifications for this recipient.
|
||||
*/
|
||||
data object Unmute : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Updates the mention notification setting for this recipient.
|
||||
* Only relevant for group conversations that support @mentions.
|
||||
*
|
||||
* @param setting The new [NotificationSetting] to apply for @mention notifications.
|
||||
*/
|
||||
data class SetMentionSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Updates the call notification setting for this recipient.
|
||||
* Controls whether incoming calls still produce notifications while the conversation is muted.
|
||||
*
|
||||
* @param setting The new [NotificationSetting] to apply for call notifications.
|
||||
*/
|
||||
data class SetCallNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Updates the reply notification setting for this recipient.
|
||||
* Controls whether replies directed at the current user still produce notifications while muted.
|
||||
*
|
||||
* @param setting The new [NotificationSetting] to apply for reply notifications.
|
||||
*/
|
||||
data class SetReplyNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
|
||||
|
||||
/**
|
||||
* Signals that the user tapped the "Custom Notifications" row and wishes to navigate to the
|
||||
* [custom notifications settings screen][org.thoughtcrime.securesms.components.settings.conversation.sounds.custom.CustomNotificationsSettingsFragment].
|
||||
*/
|
||||
data object NavigateToCustomNotifications : SoundsAndNotificationsEvent
|
||||
}
|
||||
@@ -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)
|
||||
@@ -81,7 +82,7 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
|
||||
if (state.hasMentionsSupport) {
|
||||
val mentionSelection = if (state.mentionSetting == RecipientTable.MentionSetting.ALWAYS_NOTIFY) {
|
||||
val mentionSelection = if (state.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
@@ -95,9 +96,9 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
|
||||
onSelected = {
|
||||
viewModel.setMentionSetting(
|
||||
if (it == 0) {
|
||||
RecipientTable.MentionSetting.ALWAYS_NOTIFY
|
||||
RecipientTable.NotificationSetting.ALWAYS_NOTIFY
|
||||
} else {
|
||||
RecipientTable.MentionSetting.DO_NOT_NOTIFY
|
||||
RecipientTable.NotificationSetting.DO_NOT_NOTIFY
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.Navigation
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.MuteDialog
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class SoundsAndNotificationsSettingsFragment2 : ComposeFragment() {
|
||||
|
||||
private val viewModel: SoundsAndNotificationsSettingsViewModel2 by viewModels(
|
||||
factoryProducer = {
|
||||
val recipientId = SoundsAndNotificationsSettingsFragment2Args.fromBundle(requireArguments()).recipientId
|
||||
SoundsAndNotificationsSettingsViewModel2.Factory(recipientId)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (!state.channelConsistencyCheckComplete || state.recipientId == Recipient.UNKNOWN.id) {
|
||||
return
|
||||
}
|
||||
|
||||
SoundsAndNotificationsSettingsScreen(
|
||||
state = state,
|
||||
formatMuteUntil = { it.formatMutedUntil(requireContext()) },
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> {
|
||||
val action = SoundsAndNotificationsSettingsFragment2Directions
|
||||
.actionSoundsAndNotificationsSettingsFragment2ToCustomNotificationsSettingsFragment(state.recipientId)
|
||||
Navigation.findNavController(requireView()).safeNavigate(action)
|
||||
}
|
||||
else -> viewModel.onEvent(event)
|
||||
}
|
||||
},
|
||||
onNavigationClick = {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
},
|
||||
onMuteClick = {
|
||||
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner) { muteUntil ->
|
||||
viewModel.onEvent(SoundsAndNotificationsEvent.SetMuteUntil(muteUntil))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class SoundsAndNotificationsSettingsRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.MentionSetting) {
|
||||
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.NotificationSetting) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
SignalDatabase.recipients.setMentionSetting(recipientId, mentionSetting)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
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.compose.Texts
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@Composable
|
||||
fun SoundsAndNotificationsSettingsScreen(
|
||||
state: SoundsAndNotificationsSettingsState2,
|
||||
formatMuteUntil: (Long) -> String,
|
||||
onEvent: (SoundsAndNotificationsEvent) -> Unit,
|
||||
onNavigationClick: () -> Unit,
|
||||
onMuteClick: () -> Unit
|
||||
) {
|
||||
val isMuted = state.muteUntil > 0
|
||||
var showUnmuteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.ConversationSettingsFragment__sounds_and_notifications),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back)
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
// Custom notifications
|
||||
item {
|
||||
val summary = if (state.hasCustomNotificationSettings) {
|
||||
stringResource(R.string.preferences_on)
|
||||
} else {
|
||||
stringResource(R.string.preferences_off)
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications),
|
||||
label = summary,
|
||||
icon = painterResource(R.drawable.ic_speaker_24),
|
||||
onClick = { onEvent(SoundsAndNotificationsEvent.NavigateToCustomNotifications) }
|
||||
)
|
||||
}
|
||||
|
||||
// Mute
|
||||
item {
|
||||
val muteSummary = if (isMuted) {
|
||||
formatMuteUntil(state.muteUntil)
|
||||
} else {
|
||||
stringResource(R.string.SoundsAndNotificationsSettingsFragment__not_muted)
|
||||
}
|
||||
|
||||
val muteIcon = if (isMuted) {
|
||||
R.drawable.ic_bell_disabled_24
|
||||
} else {
|
||||
R.drawable.ic_bell_24
|
||||
}
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications),
|
||||
label = muteSummary,
|
||||
icon = painterResource(muteIcon),
|
||||
onClick = {
|
||||
if (isMuted) showUnmuteDialog = true else onMuteClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Divider + When muted section
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__when_muted))
|
||||
}
|
||||
|
||||
// Calls
|
||||
item {
|
||||
NotificationSettingRow(
|
||||
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
|
||||
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
|
||||
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls_dialog_message),
|
||||
icon = painterResource(CoreUiR.drawable.symbol_phone_24),
|
||||
setting = state.callNotificationSetting,
|
||||
onSelected = { onEvent(SoundsAndNotificationsEvent.SetCallNotificationSetting(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
// Mentions (only for groups)
|
||||
if (state.hasMentionsSupport) {
|
||||
item {
|
||||
NotificationSettingRow(
|
||||
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
|
||||
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
|
||||
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions_dialog_message),
|
||||
icon = painterResource(R.drawable.ic_at_24),
|
||||
setting = state.mentionSetting,
|
||||
onSelected = { onEvent(SoundsAndNotificationsEvent.SetMentionSetting(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Replies (only for groups)
|
||||
if (state.hasMentionsSupport) {
|
||||
item {
|
||||
NotificationSettingRow(
|
||||
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
|
||||
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
|
||||
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_dialog_message),
|
||||
icon = painterResource(R.drawable.symbol_reply_24),
|
||||
setting = state.replyNotificationSetting,
|
||||
onSelected = { onEvent(SoundsAndNotificationsEvent.SetReplyNotificationSetting(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showUnmuteDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = Dialogs.NoTitle,
|
||||
body = formatMuteUntil(state.muteUntil),
|
||||
confirm = stringResource(R.string.ConversationSettingsFragment__unmute),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = { onEvent(SoundsAndNotificationsEvent.Unmute) },
|
||||
onDismiss = { showUnmuteDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationSettingRow(
|
||||
title: String,
|
||||
dialogTitle: String,
|
||||
dialogMessage: String,
|
||||
icon: Painter,
|
||||
setting: NotificationSetting,
|
||||
onSelected: (NotificationSetting) -> Unit
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val labels = arrayOf(
|
||||
stringResource(R.string.SoundsAndNotificationsSettingsFragment__always_notify),
|
||||
stringResource(R.string.SoundsAndNotificationsSettingsFragment__do_not_notify)
|
||||
)
|
||||
val selectedLabel = if (setting == NotificationSetting.ALWAYS_NOTIFY) labels[0] else labels[1]
|
||||
|
||||
Rows.TextRow(
|
||||
text = title,
|
||||
label = selectedLabel,
|
||||
icon = icon,
|
||||
onClick = { showDialog = true }
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
NotificationSettingDialog(
|
||||
title = dialogTitle,
|
||||
message = dialogMessage,
|
||||
labels = labels,
|
||||
selectedIndex = if (setting == NotificationSetting.ALWAYS_NOTIFY) 0 else 1,
|
||||
onDismiss = { showDialog = false },
|
||||
onSelected = { index ->
|
||||
onSelected(if (index == 0) NotificationSetting.ALWAYS_NOTIFY else NotificationSetting.DO_NOT_NOTIFY)
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationSettingDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
labels: Array<String>,
|
||||
selectedIndex: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onSelected: (Int) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = AlertDialogDefaults.shape,
|
||||
color = SignalTheme.colors.colorSurface2
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp)
|
||||
.horizontalGutters()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.horizontalGutters()
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(top = 16.dp, bottom = 16.dp)) {
|
||||
labels.forEachIndexed { index, label ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.clickable { onSelected(index) }
|
||||
.horizontalGutters()
|
||||
) {
|
||||
RadioButton(
|
||||
selected = index == selectedIndex,
|
||||
onClick = { onSelected(index) }
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SoundsAndNotificationsSettingsScreenMutedPreview() {
|
||||
Previews.Preview {
|
||||
SoundsAndNotificationsSettingsScreen(
|
||||
state = SoundsAndNotificationsSettingsState2(
|
||||
muteUntil = Long.MAX_VALUE,
|
||||
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
replyNotificationSetting = NotificationSetting.DO_NOT_NOTIFY,
|
||||
hasMentionsSupport = true,
|
||||
hasCustomNotificationSettings = false,
|
||||
channelConsistencyCheckComplete = true
|
||||
),
|
||||
formatMuteUntil = { "Always" },
|
||||
onEvent = {},
|
||||
onNavigationClick = {},
|
||||
onMuteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SoundsAndNotificationsSettingsScreenUnmutedPreview() {
|
||||
Previews.Preview {
|
||||
SoundsAndNotificationsSettingsScreen(
|
||||
state = SoundsAndNotificationsSettingsState2(
|
||||
muteUntil = 0L,
|
||||
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
replyNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
hasMentionsSupport = false,
|
||||
hasCustomNotificationSettings = true,
|
||||
channelConsistencyCheckComplete = true
|
||||
),
|
||||
formatMuteUntil = { "" },
|
||||
onEvent = {},
|
||||
onNavigationClick = {},
|
||||
onMuteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
data class SoundsAndNotificationsSettingsState(
|
||||
val recipientId: RecipientId = Recipient.UNKNOWN.id,
|
||||
val muteUntil: Long = 0L,
|
||||
val mentionSetting: RecipientTable.MentionSetting = RecipientTable.MentionSetting.DO_NOT_NOTIFY,
|
||||
val mentionSetting: RecipientTable.NotificationSetting = RecipientTable.NotificationSetting.DO_NOT_NOTIFY,
|
||||
val hasCustomNotificationSettings: Boolean = false,
|
||||
val hasMentionsSupport: Boolean = false,
|
||||
val channelConsistencyCheckComplete: Boolean = false
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
data class SoundsAndNotificationsSettingsState2(
|
||||
val recipientId: RecipientId = Recipient.UNKNOWN.id,
|
||||
val muteUntil: Long = 0L,
|
||||
val mentionSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
val callNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
val replyNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
|
||||
val hasCustomNotificationSettings: Boolean = false,
|
||||
val hasMentionsSupport: Boolean = false,
|
||||
val channelConsistencyCheckComplete: Boolean = false
|
||||
) {
|
||||
val isMuted = muteUntil > 0
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class SoundsAndNotificationsSettingsViewModel(
|
||||
repository.setMuteUntil(recipientId, 0L)
|
||||
}
|
||||
|
||||
fun setMentionSetting(mentionSetting: RecipientTable.MentionSetting) {
|
||||
fun setMentionSetting(mentionSetting: RecipientTable.NotificationSetting) {
|
||||
repository.setMentionSetting(recipientId, mentionSetting)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class SoundsAndNotificationsSettingsViewModel2(
|
||||
private val recipientId: RecipientId
|
||||
) : ViewModel(), RecipientForeverObserver {
|
||||
|
||||
private val _state = MutableStateFlow(SoundsAndNotificationsSettingsState2())
|
||||
val state: StateFlow<SoundsAndNotificationsSettingsState2> = _state
|
||||
|
||||
private val liveRecipient = Recipient.live(recipientId)
|
||||
|
||||
init {
|
||||
liveRecipient.observeForever(this)
|
||||
onRecipientChanged(liveRecipient.get())
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (NotificationChannels.supported()) {
|
||||
NotificationChannels.getInstance().ensureCustomChannelConsistency()
|
||||
}
|
||||
_state.update { it.copy(channelConsistencyCheckComplete = true) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRecipientChanged(recipient: Recipient) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
recipientId = recipientId,
|
||||
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
|
||||
mentionSetting = recipient.mentionSetting,
|
||||
callNotificationSetting = recipient.callNotificationSetting,
|
||||
replyNotificationSetting = recipient.replyNotificationSetting,
|
||||
hasMentionsSupport = recipient.isPushV2Group,
|
||||
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
liveRecipient.removeForeverObserver(this)
|
||||
}
|
||||
|
||||
fun onEvent(event: SoundsAndNotificationsEvent) {
|
||||
when (event) {
|
||||
is SoundsAndNotificationsEvent.SetMuteUntil -> applySetMuteUntil(event.muteUntil)
|
||||
is SoundsAndNotificationsEvent.Unmute -> applySetMuteUntil(0L)
|
||||
is SoundsAndNotificationsEvent.SetMentionSetting -> applySetMentionSetting(event.setting)
|
||||
is SoundsAndNotificationsEvent.SetCallNotificationSetting -> applySetCallNotificationSetting(event.setting)
|
||||
is SoundsAndNotificationsEvent.SetReplyNotificationSetting -> applySetReplyNotificationSetting(event.setting)
|
||||
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> Unit // Navigation handled by UI
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetMuteUntil(muteUntil: Long) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
SignalDatabase.recipients.setMuted(recipientId, muteUntil)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetMentionSetting(setting: NotificationSetting) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
SignalDatabase.recipients.setMentionSetting(recipientId, setting)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetCallNotificationSetting(setting: NotificationSetting) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
SignalDatabase.recipients.setCallNotificationSetting(recipientId, setting)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySetReplyNotificationSetting(setting: NotificationSetting) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
SignalDatabase.recipients.setReplyNotificationSetting(recipientId, setting)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val recipientId: RecipientId) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel2(recipientId)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
/**
|
||||
* Renders information about a call (1:1, group, or call link) and provides actions available for
|
||||
@@ -114,6 +115,12 @@ object CallInfoView {
|
||||
onShareLinkClicked = callbacks::onShareLinkClicked,
|
||||
onEditNameClicked = onEditNameClicked,
|
||||
onBlock = callbacks::onBlock,
|
||||
onMuteAudio = callbacks::onMuteAudio,
|
||||
onRemoveFromCall = callbacks::onRemoveFromCall,
|
||||
onContactDetails = callbacks::onContactDetails,
|
||||
onViewSafetyNumber = callbacks::onViewSafetyNumber,
|
||||
onGoToChat = callbacks::onGoToChat,
|
||||
isInternalUser = RemoteConfig.internalUser,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -122,6 +129,11 @@ object CallInfoView {
|
||||
fun onShareLinkClicked()
|
||||
fun onEditNameClicked(name: String)
|
||||
fun onBlock(callParticipant: CallParticipant)
|
||||
fun onMuteAudio(callParticipant: CallParticipant)
|
||||
fun onRemoveFromCall(callParticipant: CallParticipant)
|
||||
fun onContactDetails(callParticipant: CallParticipant)
|
||||
fun onViewSafetyNumber(callParticipant: CallParticipant)
|
||||
fun onGoToChat(callParticipant: CallParticipant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +147,12 @@ private fun CallInfoPreview() {
|
||||
controlAndInfoState = ControlAndInfoState(),
|
||||
onShareLinkClicked = { },
|
||||
onEditNameClicked = { },
|
||||
onBlock = { }
|
||||
onBlock = { },
|
||||
onMuteAudio = { },
|
||||
onRemoveFromCall = { },
|
||||
onContactDetails = { },
|
||||
onViewSafetyNumber = { },
|
||||
onGoToChat = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,8 +164,15 @@ private fun CallInfo(
|
||||
onShareLinkClicked: () -> Unit,
|
||||
onEditNameClicked: () -> Unit,
|
||||
onBlock: (CallParticipant) -> Unit,
|
||||
onMuteAudio: (CallParticipant) -> Unit = {},
|
||||
onRemoveFromCall: (CallParticipant) -> Unit = {},
|
||||
onContactDetails: (CallParticipant) -> Unit = {},
|
||||
onViewSafetyNumber: (CallParticipant) -> Unit = {},
|
||||
onGoToChat: (CallParticipant) -> Unit = {},
|
||||
isInternalUser: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedParticipant by remember { mutableStateOf<CallParticipant?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(controlAndInfoState.resetScrollState) {
|
||||
@@ -252,7 +276,16 @@ private fun CallInfo(
|
||||
CallParticipantRow(
|
||||
callParticipant = it,
|
||||
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
|
||||
onBlockClicked = onBlock
|
||||
onBlockClicked = onBlock,
|
||||
onParticipantClicked = if (isInternalUser) {
|
||||
{ participant ->
|
||||
if (!participant.recipient.isSelf) {
|
||||
selectedParticipant = participant
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -312,6 +345,20 @@ private fun CallInfo(
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
|
||||
selectedParticipant?.let { participant ->
|
||||
ParticipantActionsSheet(
|
||||
callParticipant = participant,
|
||||
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
|
||||
isCallLink = controlAndInfoState.callLink != null,
|
||||
onDismiss = { selectedParticipant = null },
|
||||
onMuteAudio = onMuteAudio,
|
||||
onRemoveFromCall = onRemoveFromCall,
|
||||
onContactDetails = onContactDetails,
|
||||
onViewSafetyNumber = onViewSafetyNumber,
|
||||
onGoToChat = onGoToChat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -336,9 +383,10 @@ private fun CallParticipantRowPreview() {
|
||||
Previews.Preview {
|
||||
Surface {
|
||||
CallParticipantRow(
|
||||
CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
|
||||
isSelfAdmin = true
|
||||
) {}
|
||||
callParticipant = CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
|
||||
isSelfAdmin = true,
|
||||
onBlockClicked = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,7 +405,8 @@ private fun HandRaisedRowPreview() {
|
||||
private fun CallParticipantRow(
|
||||
callParticipant: CallParticipant,
|
||||
isSelfAdmin: Boolean,
|
||||
onBlockClicked: (CallParticipant) -> Unit
|
||||
onBlockClicked: (CallParticipant) -> Unit,
|
||||
onParticipantClicked: ((CallParticipant) -> Unit)? = null
|
||||
) {
|
||||
CallParticipantRow(
|
||||
initialRecipient = callParticipant.recipient,
|
||||
@@ -368,7 +417,12 @@ private fun CallParticipantRow(
|
||||
showHandRaised = false,
|
||||
canLowerHand = false,
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
onBlockClicked = { onBlockClicked(callParticipant) }
|
||||
onBlockClicked = { onBlockClicked(callParticipant) },
|
||||
onRowClicked = if (onParticipantClicked != null && !callParticipant.recipient.isSelf) {
|
||||
{ onParticipantClicked(callParticipant) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -396,14 +450,22 @@ private fun CallParticipantRow(
|
||||
isMicrophoneEnabled: Boolean,
|
||||
showHandRaised: Boolean,
|
||||
canLowerHand: Boolean,
|
||||
isSelfAdmin: Boolean,
|
||||
onBlockClicked: () -> Unit
|
||||
isSelfAdmin: Boolean = false,
|
||||
onBlockClicked: () -> Unit = {},
|
||||
onRowClicked: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
val rowModifier = if (onRowClicked != null) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onRowClicked)
|
||||
.padding(Rows.defaultPadding())
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Rows.defaultPadding())
|
||||
) {
|
||||
}
|
||||
|
||||
Row(modifier = rowModifier) {
|
||||
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(initialRecipient.id)))
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.toLiveData()
|
||||
@@ -512,8 +574,9 @@ private fun GroupMemberRow(
|
||||
isMicrophoneEnabled = false,
|
||||
showHandRaised = false,
|
||||
canLowerHand = false,
|
||||
isSelfAdmin = isSelfAdmin
|
||||
) {}
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
onBlockClicked = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -11,9 +11,10 @@ import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
@Immutable
|
||||
data class ControlAndInfoState(
|
||||
val callLink: CallLinkTable.CallLink? = null,
|
||||
val isGroupAdmin: Boolean = false,
|
||||
val resetScrollState: Long = 0
|
||||
) {
|
||||
fun isSelfAdmin(): Boolean {
|
||||
return callLink?.credentials?.adminPassBytes != null
|
||||
return callLink?.credentials?.adminPassBytes != null || isGroupAdmin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsRepository
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
@@ -42,6 +45,16 @@ class ControlsAndInfoViewModel(
|
||||
disposables += CallLinks.watchCallLink(recipient.requireCallLinkRoomId()).subscribeBy {
|
||||
_state.value = _state.value.copy(callLink = it)
|
||||
}
|
||||
} else if (recipient.isGroup && callRecipientId != recipient.id) {
|
||||
callRecipientId = recipient.id
|
||||
disposables += Single.fromCallable {
|
||||
val groupRecord = SignalDatabase.groups.getGroup(recipient.requireGroupId())
|
||||
groupRecord.isPresent && groupRecord.get().memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribeBy { isAdmin ->
|
||||
_state.value = _state.value.copy(isGroupAdmin = isAdmin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.controls
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.toLiveData
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.ui.compose.AllNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ParticipantActionsSheet(
|
||||
callParticipant: CallParticipant,
|
||||
isSelfAdmin: Boolean,
|
||||
isCallLink: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onMuteAudio: (CallParticipant) -> Unit,
|
||||
onRemoveFromCall: (CallParticipant) -> Unit,
|
||||
onContactDetails: (CallParticipant) -> Unit,
|
||||
onViewSafetyNumber: (CallParticipant) -> Unit,
|
||||
onGoToChat: (CallParticipant) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
val recipient by (
|
||||
(if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id))
|
||||
.toFlowable(BackpressureStrategy.LATEST)
|
||||
.toLiveData()
|
||||
.observeAsState(initial = callParticipant.recipient)
|
||||
)
|
||||
|
||||
ParticipantActionsSheetContent(
|
||||
recipient = recipient,
|
||||
callParticipant = callParticipant,
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
isCallLink = isCallLink,
|
||||
onDismiss = onDismiss,
|
||||
onMuteAudio = onMuteAudio,
|
||||
onRemoveFromCall = onRemoveFromCall,
|
||||
onContactDetails = onContactDetails,
|
||||
onViewSafetyNumber = onViewSafetyNumber,
|
||||
onGoToChat = onGoToChat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantActionsSheetContent(
|
||||
recipient: Recipient,
|
||||
callParticipant: CallParticipant,
|
||||
isSelfAdmin: Boolean,
|
||||
isCallLink: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onMuteAudio: (CallParticipant) -> Unit,
|
||||
onRemoveFromCall: (CallParticipant) -> Unit,
|
||||
onContactDetails: (CallParticipant) -> Unit,
|
||||
onViewSafetyNumber: (CallParticipant) -> Unit,
|
||||
onGoToChat: (CallParticipant) -> Unit
|
||||
) {
|
||||
ParticipantHeader(recipient = recipient)
|
||||
|
||||
val hasAdminActions = isSelfAdmin && (callParticipant.isMicrophoneEnabled || isCallLink)
|
||||
|
||||
if (hasAdminActions) {
|
||||
Dividers.Default()
|
||||
|
||||
if (callParticipant.isMicrophoneEnabled) {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallParticipantSheet__mute_audio),
|
||||
icon = painterResource(id = R.drawable.symbol_mic_slash_24),
|
||||
onClick = {
|
||||
onMuteAudio(callParticipant)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isCallLink) {
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallParticipantSheet__remove_from_call),
|
||||
icon = painterResource(id = R.drawable.symbol_minus_circle_24),
|
||||
onClick = {
|
||||
onRemoveFromCall(callParticipant)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallParticipantSheet__contact_details),
|
||||
icon = painterResource(id = R.drawable.symbol_person_24),
|
||||
onClick = {
|
||||
onContactDetails(callParticipant)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.ConversationSettingsFragment__view_safety_number),
|
||||
icon = painterResource(id = R.drawable.symbol_safety_number_24),
|
||||
onClick = {
|
||||
onViewSafetyNumber(callParticipant)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallContextMenu__go_to_chat),
|
||||
icon = painterResource(id = R.drawable.symbol_open_24),
|
||||
onClick = {
|
||||
onGoToChat(callParticipant)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantHeader(recipient: Recipient) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Spacer(modifier = Modifier.size(64.dp))
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = Modifier.size(64.dp)
|
||||
) {
|
||||
it.setAvatarUsingProfile(recipient)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
|
||||
Text(
|
||||
text = recipient.getDisplayName(androidx.compose.ui.platform.LocalContext.current),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
val e164 = recipient.e164
|
||||
if (e164.isPresent) {
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
Text(
|
||||
text = e164.get(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun ParticipantActionsSheetAdminPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
ParticipantActionsSheetContent(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
|
||||
callParticipant = CallParticipant(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
|
||||
isMicrophoneEnabled = true
|
||||
),
|
||||
isSelfAdmin = true,
|
||||
isCallLink = true,
|
||||
onDismiss = {},
|
||||
onMuteAudio = {},
|
||||
onRemoveFromCall = {},
|
||||
onContactDetails = {},
|
||||
onViewSafetyNumber = {},
|
||||
onGoToChat = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun ParticipantActionsSheetNonAdminPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
ParticipantActionsSheetContent(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy"),
|
||||
callParticipant = CallParticipant(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
|
||||
),
|
||||
isSelfAdmin = false,
|
||||
isCallLink = false,
|
||||
onDismiss = {},
|
||||
onMuteAudio = {},
|
||||
onRemoveFromCall = {},
|
||||
onContactDetails = {},
|
||||
onViewSafetyNumber = {},
|
||||
onGoToChat = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -15,10 +15,13 @@ import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.CallLinks
|
||||
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
|
||||
|
||||
/**
|
||||
* Callbacks for the CallInfoView, shared between CallActivity and ControlsAndInfoController.
|
||||
@@ -60,4 +63,34 @@ class CallInfoCallbacks(
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onMuteAudio(callParticipant: CallParticipant) {
|
||||
AppDependencies.signalCallManager.sendRemoteMuteRequest(callParticipant)
|
||||
}
|
||||
|
||||
override fun onRemoveFromCall(callParticipant: CallParticipant) {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setMessage(activity.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(activity)))
|
||||
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
|
||||
AppDependencies.signalCallManager.removeFromCallLink(callParticipant)
|
||||
}
|
||||
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
|
||||
AppDependencies.signalCallManager.blockFromCallLink(callParticipant)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onContactDetails(callParticipant: CallParticipant) {
|
||||
activity.startActivity(ConversationSettingsActivity.forRecipient(activity, callParticipant.recipient.id))
|
||||
}
|
||||
|
||||
override fun onViewSafetyNumber(callParticipant: CallParticipant) {
|
||||
val identityRecord = AppDependencies.protocolStore.aci().identities().getIdentityRecord(callParticipant.recipient.id)
|
||||
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(activity, identityRecord.orElse(null))
|
||||
}
|
||||
|
||||
override fun onGoToChat(callParticipant: CallParticipant) {
|
||||
CommunicationActions.startConversation(activity, callParticipant.recipient, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
@@ -15,7 +16,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import org.signal.core.ui.compose.AllNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
|
||||
@@ -28,12 +31,17 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
fun CallParticipantsPager(
|
||||
callParticipantsPagerState: CallParticipantsPagerState,
|
||||
pagerState: PagerState,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onTap: (() -> Unit)? = null,
|
||||
onParticipantLongPress: ((CallParticipant) -> Unit)? = null
|
||||
) {
|
||||
if (callParticipantsPagerState.focusedParticipant == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val currentOnTap = rememberUpdatedState(onTap)
|
||||
val currentOnLongPress = rememberUpdatedState(onParticipantLongPress)
|
||||
|
||||
val firstParticipantAR = rememberParticipantAspectRatio(
|
||||
callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink
|
||||
)
|
||||
@@ -48,13 +56,24 @@ fun CallParticipantsPager(
|
||||
modifier = mod,
|
||||
itemKey = { it.callParticipantId }
|
||||
) { participant, itemModifier ->
|
||||
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
|
||||
itemModifier.pointerInput(participant.callParticipantId) {
|
||||
detectTapGestures(
|
||||
onTap = { currentOnTap.value?.invoke() },
|
||||
onLongPress = { currentOnLongPress.value?.invoke(participant) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemModifier
|
||||
}
|
||||
|
||||
RemoteParticipantContent(
|
||||
participant = participant,
|
||||
renderInPip = state.isRenderInPip,
|
||||
raiseHandAllowed = false,
|
||||
onInfoMoreInfoClick = null,
|
||||
showAudioIndicator = state.callParticipants.size > 1,
|
||||
modifier = itemModifier
|
||||
modifier = longPressModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -25,7 +26,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SheetValue
|
||||
@@ -35,6 +39,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@@ -48,6 +53,8 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -57,6 +64,7 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.TriggerAlignedPopupState
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiStrings
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
|
||||
@@ -118,7 +126,15 @@ fun CallScreen(
|
||||
onCallScreenDialogDismissed: () -> Unit = {},
|
||||
onWifiToCellularPopupDismissed: () -> Unit = {},
|
||||
onSwipeToSpeakerHintDismissed: () -> Unit = {},
|
||||
onRemoteMuteToastDismissed: () -> Unit = {}
|
||||
onRemoteMuteToastDismissed: () -> Unit = {},
|
||||
isInternalUser: Boolean = false,
|
||||
isSelfAdmin: Boolean = false,
|
||||
isCallLink: Boolean = false,
|
||||
onMuteAudio: (CallParticipant) -> Unit = {},
|
||||
onRemoveFromCall: (CallParticipant) -> Unit = {},
|
||||
onContactDetails: (CallParticipant) -> Unit = {},
|
||||
onViewSafetyNumber: (CallParticipant) -> Unit = {},
|
||||
onGoToChat: (CallParticipant) -> Unit = {}
|
||||
) {
|
||||
if (webRtcCallState == WebRtcViewModel.State.CALL_INCOMING) {
|
||||
IncomingCallScreen(
|
||||
@@ -180,11 +196,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,
|
||||
@@ -311,22 +328,53 @@ fun CallScreen(
|
||||
)
|
||||
}
|
||||
} else if (webRtcCallState.isPassedPreJoin) {
|
||||
var longPressedParticipantId by remember { mutableStateOf<CallParticipantId?>(null) }
|
||||
val longPressedParticipant = longPressedParticipantId?.let { id ->
|
||||
callParticipantsPagerState.callParticipants.find { it.callParticipantId == id }
|
||||
}
|
||||
|
||||
CallElementsLayout(
|
||||
callGridSlot = {
|
||||
CallParticipantsPager(
|
||||
callParticipantsPagerState = callParticipantsPagerState,
|
||||
pagerState = callScreenController.callParticipantsVerticalPagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
onClick = {
|
||||
Box {
|
||||
CallParticipantsPager(
|
||||
callParticipantsPagerState = callParticipantsPagerState,
|
||||
pagerState = callScreenController.callParticipantsVerticalPagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
|
||||
}
|
||||
},
|
||||
enabled = !callControlsState.skipHiddenState
|
||||
),
|
||||
onTap = {
|
||||
if (!callControlsState.skipHiddenState) {
|
||||
scope.launch {
|
||||
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
|
||||
}
|
||||
},
|
||||
enabled = !callControlsState.skipHiddenState
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onParticipantLongPress = if (isInternalUser) {
|
||||
{ participant -> longPressedParticipantId = participant.callParticipantId }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantContextMenu(
|
||||
participant = longPressedParticipant,
|
||||
isSelfAdmin = isSelfAdmin,
|
||||
isCallLink = isCallLink,
|
||||
onDismiss = { longPressedParticipantId = null },
|
||||
onMuteAudio = onMuteAudio,
|
||||
onRemoveFromCall = onRemoveFromCall,
|
||||
onContactDetails = onContactDetails,
|
||||
onViewSafetyNumber = onViewSafetyNumber,
|
||||
onGoToChat = onGoToChat
|
||||
)
|
||||
}
|
||||
},
|
||||
pictureInPictureSlot = {
|
||||
MoveableLocalVideoRenderer(
|
||||
@@ -517,6 +565,140 @@ private fun AnimatedCallStateUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantContextMenu(
|
||||
participant: CallParticipant?,
|
||||
isSelfAdmin: Boolean,
|
||||
isCallLink: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onMuteAudio: (CallParticipant) -> Unit,
|
||||
onRemoveFromCall: (CallParticipant) -> Unit,
|
||||
onContactDetails: (CallParticipant) -> Unit,
|
||||
onViewSafetyNumber: (CallParticipant) -> Unit,
|
||||
onGoToChat: (CallParticipant) -> Unit
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = participant != null,
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
val resolved = participant ?: return@DropdownMenu
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = resolved.recipient.getShortDisplayName(androidx.compose.ui.platform.LocalContext.current),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
|
||||
// Divider (default divider has too much padding)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.5.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||
)
|
||||
|
||||
if (isSelfAdmin && resolved.isMicrophoneEnabled) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.CallParticipantSheet__mute_audio)) },
|
||||
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_mic_slash_24), contentDescription = null) },
|
||||
onClick = {
|
||||
onMuteAudio(resolved)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelfAdmin && isCallLink) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.CallParticipantSheet__remove_from_call)) },
|
||||
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_minus_circle_24), contentDescription = null) },
|
||||
onClick = {
|
||||
onRemoveFromCall(resolved)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.CallParticipantSheet__contact_details)) },
|
||||
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_person_24), contentDescription = null) },
|
||||
onClick = {
|
||||
onContactDetails(resolved)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.ConversationSettingsFragment__view_safety_number)) },
|
||||
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_safety_number_24), contentDescription = null) },
|
||||
onClick = {
|
||||
onViewSafetyNumber(resolved)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.CallContextMenu__go_to_chat)) },
|
||||
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_open_24), contentDescription = null) },
|
||||
onClick = {
|
||||
onGoToChat(resolved)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun ParticipantContextMenuAdminPreview() {
|
||||
Previews.Preview {
|
||||
Box {
|
||||
ParticipantContextMenu(
|
||||
participant = CallParticipant(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
|
||||
isMicrophoneEnabled = true
|
||||
),
|
||||
isSelfAdmin = true,
|
||||
isCallLink = true,
|
||||
onDismiss = {},
|
||||
onMuteAudio = {},
|
||||
onRemoveFromCall = {},
|
||||
onContactDetails = {},
|
||||
onViewSafetyNumber = {},
|
||||
onGoToChat = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun ParticipantContextMenuNonAdminPreview() {
|
||||
Previews.Preview {
|
||||
Box {
|
||||
ParticipantContextMenu(
|
||||
participant = CallParticipant(
|
||||
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
|
||||
),
|
||||
isSelfAdmin = false,
|
||||
isCallLink = false,
|
||||
onDismiss = {},
|
||||
onMuteAudio = {},
|
||||
onRemoveFromCall = {},
|
||||
onContactDetails = {},
|
||||
onViewSafetyNumber = {},
|
||||
onGoToChat = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllNightPreviews
|
||||
@Composable
|
||||
private fun CallScreenPreview() {
|
||||
|
||||
@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.WindowUtil
|
||||
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -173,6 +174,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
|
||||
}
|
||||
}
|
||||
|
||||
val controlAndInfoState by controlsAndInfoViewModel.state
|
||||
|
||||
SignalTheme(isDarkMode = true) {
|
||||
CallScreen(
|
||||
callRecipient = recipient,
|
||||
@@ -217,7 +220,15 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
|
||||
onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } },
|
||||
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
|
||||
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
|
||||
callParticipantUpdatePopupController = callParticipantUpdatePopupController
|
||||
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
|
||||
isInternalUser = RemoteConfig.internalUser,
|
||||
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
|
||||
isCallLink = controlAndInfoState.callLink != null,
|
||||
onMuteAudio = callInfoCallbacks::onMuteAudio,
|
||||
onRemoveFromCall = callInfoCallbacks::onRemoveFromCall,
|
||||
onContactDetails = callInfoCallbacks::onContactDetails,
|
||||
onViewSafetyNumber = callInfoCallbacks::onViewSafetyNumber,
|
||||
onGoToChat = callInfoCallbacks::onGoToChat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user