Compare commits

...

103 Commits

Author SHA1 Message Date
Greyson Parrelli
36b626941f Bump version to 8.1.4 2026-03-06 15:56:02 -05:00
Greyson Parrelli
0605cc0a9c Update delete column migration to use a single insert. 2026-03-06 15:55:33 -05:00
Greyson Parrelli
43e7d65af5 Bump version to 8.1.3 2026-03-04 13:41:59 -05:00
Greyson Parrelli
386d8bb312 Update translations and other static files. 2026-03-04 13:41:32 -05:00
Michelle Tang
3fbd72092c Use batch inserting migration instead. 2026-03-02 17:30:54 -05:00
Greyson Parrelli
4e5b15cd88 Never notify for quotes in muted 1:1 chats. 2026-03-02 13:58:02 -05:00
Greyson Parrelli
8b2aeba3bd Bump version to 8.1.2 2026-02-27 22:44:39 -05:00
Greyson Parrelli
1d2334b920 Update translations and other static files. 2026-02-27 22:44:11 -05:00
jeffrey-signal
38a234ae66 Fix crash after inviting group members. 2026-02-27 22:29:06 -05:00
jeffrey-signal
2c1226dc02 Fix groups v1 migration suggestions dialog crash. 2026-02-27 22:27:58 -05:00
Greyson Parrelli
1df8ef6464 Fix backup import issue when we dedude messages with edits. 2026-02-27 16:25:30 -05:00
Alex Hart
f8d40bf86d Revert "Don't show 'Payment Pending' during backup subscription keep-alive flows."
This reverts commit e87aa22d32.
2026-02-27 17:02:15 -04:00
Alex Hart
58ab03b4e3 Fix crash when enabling vanity camera before capturer initialization. 2026-02-27 16:35:39 -04:00
Michelle Tang
0bf54e6b45 Fix network crash when unpinning. 2026-02-27 15:19:48 -05:00
jeffrey-signal
8fca0c69ac Bump version to 8.1.1 2026-02-26 21:51:58 -05:00
jeffrey-signal
70eb4ca2a1 Update baseline profile. 2026-02-26 21:29:00 -05:00
jeffrey-signal
9d9e30725e Update translations and other static files. 2026-02-26 21:20:51 -05:00
jeffrey-signal
ff9585ec7d Show member labels on the admin sheet. 2026-02-26 20:00:36 -05:00
Greyson Parrelli
a418c2750a Fix mute icons theming. 2026-02-26 13:54:11 -05:00
jeffrey-signal
9581994050 Handle network and permissions errors when saving group member label. 2026-02-26 10:34:16 -05:00
jeffrey-signal
316d0e67c5 Enforce member label emoji and text constraints. 2026-02-26 08:32:32 -05:00
Cody Henthorne
503bf04ec5 Bump version to 8.1.0 2026-02-25 20:01:23 -05:00
Cody Henthorne
d6b76936dd Update baseline profile. 2026-02-25 19:55:25 -05:00
Cody Henthorne
c53d16717b Update translations and other static files. 2026-02-25 19:46:16 -05:00
jeffrey-signal
2c747daa50 Disable member label button for users without permission to edit. 2026-02-25 19:38:12 -05:00
jeffrey-signal
0b2d3edcce Add member labels education sheet. 2026-02-25 19:38:12 -05:00
jeffrey-signal
955bcde062 Rotate send member labels flag. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
a91aa72fb4 Guard against missing integrity check in CopyAttachmentToArchiveJob.
Add a check for hadIntegrityCheckPerformed() before attempting to copy
an attachment to the archive. If the attachment's download has failed
(transferState == FAILED), requireMediaName() would throw an
IllegalArgumentException because the integrity check was never
completed. The fix resets the archive transfer state to NONE and skips,
allowing a future successful download to re-trigger archiving.
2026-02-25 19:38:12 -05:00
Alex Hart
163ece75b2 Remove note about call links if there are no call links selected. 2026-02-25 19:38:12 -05:00
Alex Hart
a8fb5f2598 Prevent EmojiTextView measurement oscillation on size changes. 2026-02-25 19:38:12 -05:00
Alex Hart
3a62ad67e1 Fix out-of-sync audio selection.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Greyson Parrelli
48f4e1ddc6 Rotate the android.cameraXModelBlockList and android.cameraXMixedModelBlockList flags. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
c37bb96aab Only bind camera use cases that the device supports.
The new camera implementation always bound all four CameraX use cases
(preview, image capture, video capture, and image analysis) regardless
of device capabilities. On devices with LEGACY camera hardware level,
this causes image capture to fail with "Capture request failed with
reason ERROR" because the hardware cannot handle that many simultaneous
use cases.

This change makes video capture and QR scanning use case binding
conditional based on CameraXModePolicy, which already determines device
capabilities. Video capture is only bound when the device supports mixed
mode (image + video simultaneously). QR scanning analysis is only bound
when explicitly requested.
2026-02-25 19:38:12 -05:00
jeffrey-signal
a2057e20d2 Rename UiCallbacks interfaces to avoid redeclaration errors. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
577e05eb51 Make sure we transcode non-H264 video. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
65a30cf2a7 Mark attachment 404's as permanent failures. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
121f0c6134 Add custom mute until option. 2026-02-25 19:38:12 -05:00
jeffrey-signal
7d1897a9d2 Add ability to set group member label from conversation settings. 2026-02-25 19:38:12 -05:00
Alex Hart
415dbd1b61 Fix issue with joining video call from lock screen.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Alex Hart
cfc1c35203 Eliminate unnecessary utilization of SubcomposeLayout which was causing a calling crash.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Alex Hart
911d7f3be8 Fix crash occurring when user rapidly enters and leaves a call.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Cody Henthorne
c06944da13 Add receipt processing benchmark tests. 2026-02-25 19:38:12 -05:00
Alex Hart
b6dd4a3579 Fix formatting in EditMessageRevisionTest. 2026-02-25 09:49:49 -04:00
Greyson Parrelli
b057e145c5 Ensure usernames are unique regardless of casing. 2026-02-25 00:34:22 -05:00
Greyson Parrelli
772ad3b929 Show gallery button on camera screen when camera permission is denied. 2026-02-24 23:46:42 -05:00
Michelle Tang
46681868d3 Put new deleting UI behind remote config. 2026-02-24 18:21:51 -05:00
Michelle Tang
75795bd7d5 Update incoming delete message strings. 2026-02-24 18:10:16 -05:00
Greyson Parrelli
1908723fbe Prevent potential ISE in MediaPreviewV2Fragment. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
549992c08a Fix potention NPE on video recording failures. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
845704b9fe Map UNKNOWN group member role to DEFAULT during backup export. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
ba03ca5e0c Drop quotes with unexported authors during backup export. 2026-02-24 16:50:01 -05:00
Cody Henthorne
92a9f12b58 Fix notification not being dismissed for read edited message. 2026-02-24 16:50:01 -05:00
Cody Henthorne
3437ac63bb Fix group recipient being created without a group record. 2026-02-24 16:50:01 -05:00
jeffrey-signal
d798a35c38 Member labels padding, margin, and styling fixes. 2026-02-24 16:50:01 -05:00
Alex Hart
01b56995d9 Add distinctUntilChanged to speaker hint flow to prevent repeated popups.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Greyson Parrelli
3f190efb4e Validate profile keys before writing them to backup exports. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
bb6b149c2e Fix potential validation error with mentions. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
65b96fff16 Delete some dead testing code. 2026-02-24 16:50:01 -05:00
jeffrey-signal
0b8e8a7b2f Separate v1 and v2 colorizer implementations. 2026-02-24 16:50:01 -05:00
jeffrey-signal
a8a6fec19d Show preview on edit member label screen. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
a3fce4c149 Filter hidden recipients from contact-joined notifications. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
85265412da Skip trigger drop/recreate in deleteMessagesInThread when there are no messages to delete.
The deleteMessagesInThread method unconditionally drops and recreates FTS
and MSL triggers in every call, even when there are no messages matching
the delete criteria. Each trigger drop/create cycle changes the database
schema cookie, causing SQLITE_SCHEMA errors for concurrent reader
connections.
2026-02-24 16:50:01 -05:00
andrew-signal
e636a94de0 Fix bug where we constantly cycled network stack when on network with PAC proxy. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
08509f6693 Fix bug where video dimensions aren't always correct in chat view. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
d28fc98cfd Add ability to use volume buttons to capture image/video. 2026-02-24 16:50:01 -05:00
Michelle Tang
f584ef1d72 Add network constraint to admin delete job. 2026-02-24 16:50:01 -05:00
Alex Hart
67a6df57c8 Allow user to cancel in-flight keep-alive donation. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
fadbb0adc5 Enable change animations in the conversation list. 2026-02-24 16:50:01 -05:00
Michelle Tang
58774033b7 Prioritize regular delete first. 2026-02-24 16:50:01 -05:00
Cody Henthorne
66f0470960 Improve incoming group message processing. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
68137cb66f Add internal config to schedule a message after the weekend. 2026-02-24 16:50:01 -05:00
Alex Hart
4d6cacdb3d Fix call controls flickering when starting a video call.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Alex Hart
cf862af3ca Increase bank transfer minimum name limit.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Alex Hart
a8d106a292 Disable audio focus for video GIF playback in media send flow.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Michelle Tang
6155140de4 Default to allowing multiple votes. 2026-02-24 16:50:01 -05:00
Alex Hart
a4637248e8 Set megaphone snooze for backups to 2 weeks. 2026-02-24 16:50:01 -05:00
Alex Hart
8c4470a27e Add logline for full-screen intent support. 2026-02-24 16:50:01 -05:00
Michelle Tang
071fbfd916 Add support for admin delete. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
1968438ebb Improve video transcoding error logging. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
7b31383b88 Improve video encoder/decoder fallback logic. 2026-02-24 16:50:01 -05:00
Cody Henthorne
093a79045d Fix incorrect sender key state for mismatch/stale devices. 2026-02-24 16:50:01 -05:00
Cody Henthorne
e4928b0084 Fix long database transaction when syncing system contact information. 2026-02-24 16:50:01 -05:00
jeffrey-signal
03420cf501 Prevent autofill framework from treating message input as a credential field. 2026-02-24 16:50:01 -05:00
Cody Henthorne
541b4674a8 Add remote_backups cta action for release notes. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
6e108a03d1 Improve video transcode test error detection. 2026-02-24 16:50:01 -05:00
Alex Hart
c9dd332abd Pre-Registration Restoration from Local Unified Backup. 2026-02-24 16:50:01 -05:00
jeffrey-signal
7e605fb6de Fix member label emoji ignoring use system emoji preference. 2026-02-24 16:50:01 -05:00
andrew-signal
fa2b0aedb0 Bump to libsignal v0.87.4 2026-02-24 16:50:01 -05:00
andrew-signal
402f49edd9 Replace usages of old getEncryptedUsernameFromLinkServerId for libsignal's lookUpUsernameLink. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
caf2e555dd Fix more HDR transcoding errors. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
32dc36d937 Fix various transcoding issues on samsung devices. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
771d49bfa8 Add an instrumentation test for video transcoding. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
70dc78601a Exclude time from apk output filename. 2026-02-24 16:50:01 -05:00
Cody Henthorne
b4d781ddbb Reduce calls to sleep for WebSocket keep alives. 2026-02-24 16:50:01 -05:00
jeffrey-signal
9c29601b55 Consolidate about sheet state into a single object. 2026-02-24 16:50:01 -05:00
jeffrey-signal
28c37cb3ac Add ability to edit member label from the about you sheet. 2026-02-24 16:50:01 -05:00
DivyaKhunt07
bd121e47c8 Fix bubble desired height calculation. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
7428e1e2ea Improve UI for regV5 verification code submission. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
376cb926b0 Give a more readable in-app version name to the nightly. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
4ed0056d2a Preserve user zoom level when starting video recording.
Remove the unconditional zoom reset to 1x at the start of video
recording so that any pinch-to-zoom the user applied before recording
is maintained.
2026-02-24 16:50:00 -05:00
Cody Henthorne
177ef8a555 Bump version to 8.0.4 2026-02-24 16:24:04 -05:00
Cody Henthorne
7244a1f52f Update translations and other static files. 2026-02-24 16:23:32 -05:00
Alex Hart
8d311923c1 Fix possible crash when restoring fragments. 2026-02-24 17:00:47 -04:00
423 changed files with 34473 additions and 16779 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ maps.key
kls_database.db
.kotlin
lefthook-local.yml
sample-videos/

View File

@@ -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,9 +24,9 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1655
val canonicalVersionName = "8.0.3"
val currentHotfixVersion = 0
val canonicalVersionCode = 1660
val canonicalVersionName = "8.1.4"
val currentHotfixVersion = 1
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
@@ -516,7 +520,7 @@ android {
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set(tag)
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
@@ -567,7 +571,8 @@ android {
applicationVariants.configureEach {
outputs.configureEach {
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
outputFileName = outputFileName.replace(".apk", "-$versionName.apk")
val fileVersionName = versionName.substringBefore(" |")
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
}
}
}
@@ -806,6 +811,16 @@ fun getNightlyBuildNumber(tag: String?): Int {
return match?.groupValues?.get(1)?.toIntOrNull() ?: 0
}
fun getLastCommitDateTimeUtc(): String {
val timestamp = providers.exec {
commandLine("git", "log", "-1", "--pretty=format:%ct")
}.standardOutput.asText.get().trim().toLong()
val instant = Instant.ofEpochSecond(timestamp)
val formatter = DateTimeFormatter.ofPattern("MMM d '@' HH:mm 'UTC'", Locale.US)
.withZone(ZoneOffset.UTC)
return formatter.format(instant)
}
fun getMapsKey(): String {
return providers
.gradleProperty("mapsKey")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
@@ -45,6 +50,8 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
when (command) {
"individual-send" -> handlePrepareIndividualSend()
"group-send" -> handlePrepareGroupSend()
"group-delivery-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundDeliveryReceipts(timestamps) }
"group-read-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundReadReceipts(timestamps) }
"release-messages" -> {
BenchmarkWebSocketConnection.authInstance.startWholeBatchTrace = true
BenchmarkWebSocketConnection.authInstance.releaseMessages()
@@ -113,6 +120,62 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
}
private fun handlePrepareGroupReceipts(generateReceipts: (OtherClient, List<Long>) -> List<Envelope>) {
val clients = Harness.otherClients.take(5)
establishGroupSessions(clients)
val timestamps = getOutgoingGroupMessageTimestamps()
Log.i(TAG, "Found ${timestamps.size} outgoing message timestamps for receipts")
val allClientEnvelopes = clients.map { client ->
generateReceipts(client, timestamps).map { it.toWebSocketPayload() }
}
BenchmarkWebSocketConnection.authInstance.addPendingMessages(interleave(allClientEnvelopes))
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
}
private fun establishGroupSessions(clients: List<OtherClient>) {
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
releaseMessages()
ThreadUtil.sleep(1000)
}
}
}
}
private fun getOutgoingGroupMessageTimestamps(): List<Long> {
val groupId = GroupId.v2(Harness.groupMasterKey)
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val selfId = Recipient.self().id.toLong()
return TestDbUtils.getOutgoingMessageTimestamps(threadId, selfId)
}
/**
* Interleaves lists so that items from different lists alternate:
* [[a1, a2], [b1, b2], [c1, c2]] -> [a1, b1, c1, a2, b2, c2]
*/
private fun <T> interleave(lists: List<List<T>>): List<T> {
val result = mutableListOf<T>()
val maxSize = lists.maxOf { it.size }
for (i in 0 until maxSize) {
for (list in lists) {
if (i < list.size) {
result += list[i]
}
}
}
return result
}
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage {
return WebSocketRequestMessage(
verb = "PUT",

View File

@@ -6,7 +6,10 @@ import org.signal.benchmark.setup.TestMessages
import org.signal.benchmark.setup.TestUsers
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.TextSecurePreferences
class BenchmarkSetupActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -17,6 +20,8 @@ class BenchmarkSetupActivity : BaseActivity() {
"conversation-open" -> setupConversationOpen()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
}
val textView: TextView = TextView(this).apply {
@@ -68,4 +73,40 @@ class BenchmarkSetupActivity : BaseActivity() {
TestUsers.setupSelf()
TestUsers.setupGroup()
}
private fun setupGroupReceipt(includeMsl: Boolean = false, enableReadReceipts: Boolean = false) {
TestUsers.setupSelf()
val groupId = TestUsers.setupGroup()
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val messageIds = mutableListOf<Long>()
val timestamps = mutableListOf<Long>()
val baseTimestamp = 2_000_000L
for (i in 0 until 100) {
val timestamp = baseTimestamp + i
val message = OutgoingMessage(
recipient = groupRecipient,
body = "Outgoing message $i",
timestamp = timestamp,
isSecure = true
)
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
SignalDatabase.messages.markAsSent(insert.messageId, true)
messageIds += insert.messageId
timestamps += timestamp
}
if (includeMsl) {
val selfId = Recipient.self().id
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
TestDbUtils.insertMessageSendLogEntries(messageIds, timestamps, memberRecipientIds)
}
if (enableReadReceipts) {
TextSecurePreferences.setReadReceiptsEnabled(this, true)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import org.signal.core.util.SqlUtil.buildArgs
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.Content
object TestDbUtils {
@@ -11,4 +13,58 @@ object TestDbUtils {
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
}
fun getOutgoingMessageTimestamps(threadId: Long, selfRecipientId: Long): List<Long> {
val timestamps = mutableListOf<Long>()
SignalDatabase.messages.databaseHelper.signalReadableDatabase.query(
MessageTable.TABLE_NAME,
arrayOf(MessageTable.DATE_SENT),
"${MessageTable.THREAD_ID} = ? AND ${MessageTable.FROM_RECIPIENT_ID} = ?",
arrayOf(threadId.toString(), selfRecipientId.toString()),
null,
null,
"${MessageTable.DATE_SENT} ASC"
).use { cursor ->
while (cursor.moveToNext()) {
timestamps += cursor.getLong(0)
}
}
return timestamps
}
fun insertMessageSendLogEntries(messageIds: List<Long>, timestamps: List<Long>, recipientIds: List<RecipientId>) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
val dummyContent = Content.Builder().build().encode()
db.beginTransaction()
try {
for (i in messageIds.indices) {
val payloadValues = ContentValues().apply {
put("date_sent", timestamps[i])
put("content", dummyContent)
put("content_hint", 0)
put("urgent", 1)
}
val payloadId = db.insert("msl_payload", null, payloadValues)
val messageValues = ContentValues().apply {
put("payload_id", payloadId)
put("message_id", messageIds[i])
}
db.insert("msl_message", null, messageValues)
for (recipientId in recipientIds) {
val recipientValues = ContentValues().apply {
put("payload_id", payloadId)
put("recipient_id", recipientId.toLong())
put("device", 1)
}
db.insert("msl_recipient", null, recipientValues)
}
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}

View File

@@ -105,7 +105,6 @@ class ConversationElementGenerator {
false,
emptyList(),
false,
false,
now,
true,
now,
@@ -122,6 +121,7 @@ class ConversationElementGenerator {
0,
false,
0,
null,
null
)

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -214,6 +214,10 @@ object ExportOddities {
return log(0, "Distribution list had self as a member. Removing it.")
}
fun quoteAuthorNotFound(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote author was not found in the exported recipients. Removing the quote.")
}
fun emptyQuote(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
}
@@ -283,6 +287,10 @@ object ImportSkips {
return log(0, "Missing recipient for chat $chatId")
}
fun missingAdminDeleteRecipient(sentTimestamp: Long, chatId: Long): String {
return log(sentTimestamp, "Missing admin delete recipient for chat $chatId")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}

View File

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

View File

@@ -19,7 +19,6 @@ import org.signal.core.util.UuidUtil
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.emptyIfNull
import org.signal.core.util.isEmpty
import org.signal.core.util.isNotEmpty
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.kibiBytes
@@ -42,6 +41,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ExportOddities
import org.thoughtcrime.securesms.backup.v2.ExportSkips
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.proto.AdminDeletedMessage
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
@@ -193,11 +193,16 @@ class ChatItemArchiveExporter(
}
when {
record.remoteDeleted -> {
record.deletedBy == record.fromRecipientId -> {
builder.remoteDeletedMessage = RemoteDeletedMessage()
transformTimer.emit("remote-delete")
}
record.deletedBy != null -> {
builder.adminDeletedMessage = AdminDeletedMessage(adminId = record.deletedBy)
transformTimer.emit("admin-delete")
}
MessageTypes.isJoinedType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
transformTimer.emit("simple-update")
@@ -564,7 +569,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
}
val direction = when {
record.type.isDirectionlessType() && !record.remoteDeleted -> {
record.type.isDirectionlessType() && record.deletedBy == null -> {
Direction.DIRECTIONLESS
}
MessageTypes.isOutgoingMessageType(record.type) || record.fromRecipientId == selfRecipientId.toLong() -> {
@@ -1169,6 +1174,11 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
return null
}
if (!exportState.recipientIds.contains(this.quoteAuthor)) {
Log.w(TAG, ExportOddities.quoteAuthorNotFound(this.dateSent))
return null
}
val localType = QuoteModel.Type.fromCode(this.quoteType)
val remoteType = when (localType) {
QuoteModel.Type.NORMAL -> {
@@ -1360,11 +1370,12 @@ private fun FailureReason?.toRemote(): PaymentNotification.TransactionDetails.Fa
}
private fun List<Mention>.toRemoteBodyRanges(exportState: ExportState): List<BackupBodyRange> {
return this.map {
return this.mapNotNull {
val aci = exportState.recipientIdToAci[it.recipientId.toLong()] ?: return@mapNotNull null
BackupBodyRange(
start = it.start,
length = it.length,
mentionAci = exportState.recipientIdToAci[it.recipientId.toLong()]
mentionAci = aci
)
}
}
@@ -1662,7 +1673,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
this.giftBadge == null &&
this.viewOnceMessage == null &&
this.directStoryReplyMessage == null &&
this.poll == null
this.poll == null &&
this.adminDeletedMessage == null
) {
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
return null
@@ -1805,7 +1817,6 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID),
expiresIn = expiresIn,
expireStarted = expireStarted,
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
linkPreview = this.requireString(MessageTable.LINK_PREVIEWS),
sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS),
@@ -1830,6 +1841,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
pinnedAt = this.requireLong(MessageTable.PINNED_AT),
pinnedUntil = this.requireLong(MessageTable.PINNED_UNTIL),
deletedBy = this.requireLongOrNull(MessageTable.DELETED_BY),
messageExtrasSize = messageExtras?.size ?: 0
)
}
@@ -1847,7 +1859,6 @@ private class BackupMessageRecord(
val toRecipientId: Long,
val expiresIn: Long,
val expireStarted: Long,
val remoteDeleted: Boolean,
val sealedSender: Boolean,
val linkPreview: String?,
val sharedContacts: String?,
@@ -1872,6 +1883,7 @@ private class BackupMessageRecord(
val viewOnce: Boolean,
val pinnedAt: Long,
val pinnedUntil: Long,
val deletedBy: Long?,
private val messageExtrasSize: Int
) {
val estimatedSizeInBytes: Int = (body?.length ?: 0) +

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -264,6 +264,8 @@ public class EmojiTextView extends AppCompatTextView {
previousOverflowText = overflowText;
useSystemEmoji = useSystemEmoji();
previousTransformationMethod = getTransformationMethod();
lastSizeChangedWidth = -1;
lastSizeChangedHeight = -1;
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
@@ -590,7 +592,7 @@ public class EmojiTextView extends AppCompatTextView {
lastSizeChangedWidth = w;
lastSizeChangedHeight = h;
if (!sizeChangeInProgress) {
if (!sizeChangeInProgress && getMaxLines() > 0 && getMaxLines() < Integer.MAX_VALUE) {
sizeChangeInProgress = true;
resetText();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,11 @@
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -31,6 +29,7 @@ import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Launchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
@@ -158,11 +157,10 @@ class LocalBackupsFragment : ComposeFragment() {
}
@Composable
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
val context = LocalContext.current
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == Activity.RESULT_OK && uri != null) {
return Launchers.rememberOpenDocumentTreeLauncher { uri ->
if (uri != null) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)

View File

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

View File

@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.formatHours
import java.text.NumberFormat
@@ -96,7 +95,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
val clientDeprecated = SignalStore.misc.isClientDeprecated
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
if (SignalStore.backup.newLocalBackupsEnabled) {
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {

View File

@@ -798,6 +798,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Add remote backups note"),
onClick = {
viewModel.addSampleReleaseNote("remote_backups")
}
)
clickPref(
title = DSLSettingsText.from("Add remote donate megaphone"),
onClick = {

View File

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

View File

@@ -154,8 +154,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun addSampleReleaseNote() {
repository.addSampleReleaseNote()
fun addSampleReleaseNote(callToAction: String = "action") {
repository.addSampleReleaseNote(callToAction)
}
fun addRemoteDonateMegaphone() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,10 +35,10 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.getParcelableArrayListExtraCompat
import org.signal.core.util.orNull
import org.signal.core.util.requireParcelableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.AvatarPreviewActivity
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -69,9 +69,10 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.R
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
@@ -119,15 +120,16 @@ private const val REQUEST_CODE_ADD_CONTACT = 2
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
class ConversationSettingsFragment : DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
class ConversationSettingsFragment :
DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
private val args: ConversationSettingsFragmentArgs by navArgs()
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
private val colorizer = Colorizer()
private val colorizer = ColorizerV2()
private val blockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
@@ -189,6 +191,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
super.onViewCreated(view, savedInstanceState)
parentFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java)
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
parentFragmentManager.setFragmentResultListener(AboutSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(AboutSheet.RESULT_GROUP_ID, GroupId.V2::class.java)
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
}
@@ -469,9 +481,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
YouAreAlreadyInACallSnackbar.show(requireView())
}
},
onMuteClick = {
onMuteClick = { view ->
if (!state.buttonStripState.isMuted) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
MuteContextMenu.show(view, requireView() as ViewGroup, childFragmentManager, viewLifecycleOwner) { duration ->
viewModel.setMuteUntil(duration)
}
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))
@@ -739,7 +753,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
customPref(
RecipientPreference.Model(
recipient = group,
onClick = {
onRowClick = {
CommunicationActions.startConversation(requireActivity(), group, null)
requireActivity().finish()
}
@@ -787,13 +801,26 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
for (member in groupState.members) {
val canSetMemberLabel = member.member.isSelf && groupState.canSetOwnMemberLabel
val memberLabel = member.getMemberLabel(groupState)
customPref(
RecipientPreference.Model(
recipient = member.member,
isAdmin = member.isAdmin,
memberLabel = member.getMemberLabel(groupState),
memberLabel = memberLabel,
canSetMemberLabel = canSetMemberLabel,
lifecycleOwner = viewLifecycleOwner,
onClick = {
onRowClick = {
if (canSetMemberLabel && memberLabel == null) {
val action = ConversationSettingsFragmentDirections
.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
navController.safeNavigate(action)
} else {
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
}
},
onAvatarClick = {
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
}
)
@@ -826,13 +853,17 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
if (RemoteConfig.sendMemberLabels) {
val canSetMemberLabel = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = canSetMemberLabel,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
navController.safeNavigate(action)
},
onDisabledClicked = {
Snackbar.make(requireView(), R.string.GroupMemberLabel__error_no_edit_permission, Snackbar.LENGTH_SHORT).show()
}
)
}
@@ -1013,7 +1044,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) {

View File

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

View File

@@ -362,6 +362,7 @@ sealed class ConversationSettingsViewModel(
if (groupId.isV2) {
loadMemberLabels(groupId.requireV2(), fullMembers)
loadCanSetMemberLabel(groupId.requireV2())
}
state.copy(
@@ -520,6 +521,17 @@ sealed class ConversationSettingsViewModel(
)
}
}
private fun loadCanSetMemberLabel(v2GroupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
val canSetLabel = MemberLabelRepository.instance.canSetLabel(v2GroupId, Recipient.self())
store.update {
it.copy(
specificSettingsState = it.requireGroupSettingsState().copy(
canSetOwnMemberLabel = canSetLabel
)
)
}
}
}
class Factory(

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,6 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.text.SpannableStringBuilder
import android.view.View
import android.widget.TextView
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
@@ -36,8 +34,10 @@ object RecipientPreference {
val recipient: Recipient,
val isAdmin: Boolean = false,
val memberLabel: StyledMemberLabel? = null,
val canSetMemberLabel: Boolean = false,
val lifecycleOwner: LifecycleOwner? = null,
val onClick: (() -> Unit)? = null
val onRowClick: (() -> Unit)? = null,
val onAvatarClick: (() -> Unit)? = null
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id
@@ -47,7 +47,8 @@ object RecipientPreference {
return super.areContentsTheSame(newItem) &&
recipient.hasSameContent(newItem.recipient) &&
isAdmin == newItem.isAdmin &&
memberLabel == newItem.memberLabel
memberLabel == newItem.memberLabel &&
canSetMemberLabel == newItem.canSetMemberLabel
}
}
@@ -56,28 +57,36 @@ object RecipientPreference {
private val name: TextView = itemView.findViewById(R.id.recipient_name)
private val about: TextView? = itemView.findViewById(R.id.recipient_about)
private val memberLabelView: MemberLabelPillView? = itemView.findViewById(R.id.recipient_member_label)
private val addMemberLabelView: TextView? = itemView.findViewById(R.id.add_member_label)
private val admin: View? = itemView.findViewById(R.id.admin)
private val badge: BadgeImageView = itemView.findViewById(R.id.recipient_badge)
private var recipient: Recipient? = null
private var canSetMemberLabel: Boolean = false
private val recipientObserver = Observer<Recipient> { recipient ->
onRecipientChanged(recipient)
onRecipientChanged(recipient = recipient, memberLabel = null, canSetMemberLabel = canSetMemberLabel)
}
override fun bind(model: Model) {
if (model.onClick != null) {
itemView.setOnClickListener { model.onClick.invoke() }
if (model.onRowClick != null) {
itemView.setOnClickListener { model.onRowClick.invoke() }
} else {
itemView.setOnClickListener(null)
}
if (model.onAvatarClick != null) {
avatar.setOnClickListener { model.onAvatarClick.invoke() }
} else {
avatar.setOnClickListener(null)
}
canSetMemberLabel = model.canSetMemberLabel
if (model.lifecycleOwner != null) {
observeRecipient(model.lifecycleOwner, model.recipient)
model.memberLabel?.let(::showMemberLabel)
} else {
onRecipientChanged(model.recipient, model.memberLabel)
}
onRecipientChanged(model.recipient, model.memberLabel, model.canSetMemberLabel)
admin?.visible = model.isAdmin
}
@@ -86,7 +95,7 @@ object RecipientPreference {
unbind()
}
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null) {
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null, canSetMemberLabel: Boolean = false) {
avatar.setRecipient(recipient)
badge.setBadgeFromRecipient(recipient)
name.text = if (recipient.isSelf) {
@@ -104,17 +113,17 @@ object RecipientPreference {
}
}
val aboutText = recipient.combinedAboutAndEmoji
when {
memberLabel != null -> showMemberLabel(memberLabel)
!recipient.combinedAboutAndEmoji.isNullOrEmpty() -> {
about?.text = recipient.combinedAboutAndEmoji
about?.visible = true
memberLabelView?.visible = false
}
recipient.isSelf && canSetMemberLabel -> showAddMemberLabel()
!aboutText.isNullOrBlank() -> showAbout(aboutText)
else -> {
memberLabelView?.visible = false
addMemberLabelView?.visible = false
about?.visible = false
}
}
@@ -122,18 +131,29 @@ object RecipientPreference {
private fun showMemberLabel(styledLabel: StyledMemberLabel) {
memberLabelView?.apply {
style = MemberLabelPillView.Style(
horizontalPadding = 8.dp,
verticalPadding = 2.dp,
textStyle = { MaterialTheme.typography.labelSmall }
)
style = MemberLabelPillView.Style.Compact
setLabel(styledLabel.label, styledLabel.tintColor)
visible = true
}
addMemberLabelView?.visible = false
about?.visible = false
}
private fun showAddMemberLabel() {
addMemberLabelView?.visible = true
memberLabelView?.visible = false
about?.visible = false
}
private fun showAbout(text: String) {
about?.text = text
about?.visible = true
memberLabelView?.visible = false
addMemberLabelView?.visible = false
}
private fun observeRecipient(lifecycleOwner: LifecycleOwner?, recipient: Recipient?) {
this.recipient?.live()?.liveData?.removeObserver(recipientObserver)

View File

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

View File

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

View File

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

View File

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

View File

@@ -180,11 +180,12 @@ fun CallScreen(
val maxOffset = maxHeight - maxSheetHeight
var peekHeight by remember { mutableFloatStateOf(88f) }
val effectivePeekHeight = if (callControlsState.hasAnyControls) peekHeight else 0f
BottomSheetScaffold(
scaffoldState = callScreenController.scaffoldState,
sheetDragHandle = null,
sheetPeekHeight = peekHeight.dp,
sheetPeekHeight = effectivePeekHeight.dp,
sheetContainerColor = SignalTheme.colors.colorSurface1,
containerColor = Color.Black,
sheetMaxWidth = CallScreenMetrics.SheetMaxWidth,

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.io.IOException
import java.util.Calendar
import java.util.LinkedList
/**
* Methods for discovering which users are registered and marking them as such in the database.
@@ -208,7 +209,7 @@ object ContactDiscovery {
if (!SignalStore.settings.isNotifyWhenContactJoinsSignal) return
Recipient.resolvedList(newUserIds)
.filter { !it.isSelf && it.hasAUserSetDisplayName(context) && !hasSession(it.id) && it.hasE164 && !it.isBlocked }
.filter { !it.isSelf && !it.isHidden && it.hasAUserSetDisplayName(context) && !hasSession(it.id) && it.hasE164 && !it.isBlocked }
.map {
Log.i(TAG, "Inserting 'contact joined' message for ${it.id}. E164: ${it.e164}")
val message = IncomingMessage.contactJoined(it.id, System.currentTimeMillis())
@@ -240,7 +241,8 @@ object ContactDiscovery {
clearInfoForMissingContacts: Boolean
) {
val localNumber: String = SignalStore.account.e164 ?: ""
val handle = SignalDatabase.recipients.beginBulkSystemContactUpdate(clearInfoForMissingContacts)
val contactInfos = LinkedList<ContactInfo>()
try {
contactsProvider().use { iterator ->
while (iterator.hasNext()) {
@@ -262,22 +264,42 @@ object ContactDiscovery {
val recipient: Recipient = Recipient.externalContact(realNumber) ?: continue
handle.setSystemContactInfo(
recipient.id,
profileName,
phoneDetails.displayName,
phoneDetails.photoUri,
phoneDetails.label,
phoneDetails.type,
phoneDetails.contactUri.toString()
contactInfos.add(
ContactInfo(
recipientId = recipient.id,
profileName = profileName,
displayName = phoneDetails.displayName,
photoUri = phoneDetails.photoUri,
label = phoneDetails.label,
type = phoneDetails.type,
contactUri = phoneDetails.contactUri.toString()
)
)
}
}
}
} catch (e: IllegalStateException) {
Log.w(TAG, "Hit an issue with the cursor while reading!", e)
} finally {
handle.finish()
}
if (contactInfos.isNotEmpty()) {
val handle = SignalDatabase.recipients.beginBulkSystemContactUpdate(clearInfoForMissingContacts)
try {
for (contactInfo in contactInfos) {
handle.setSystemContactInfo(
id = contactInfo.recipientId,
systemProfileName = contactInfo.profileName,
systemDisplayName = contactInfo.displayName,
photoUri = contactInfo.photoUri,
systemPhoneLabel = contactInfo.label,
systemPhoneType = contactInfo.type,
systemContactUri = contactInfo.contactUri
)
}
contactInfos.clear()
} finally {
handle.finish()
}
}
if (NotificationChannels.supported()) {
@@ -318,4 +340,14 @@ object ContactDiscovery {
val pni: ServiceId.PNI,
val aci: ServiceId.ACI?
)
private class ContactInfo(
val recipientId: RecipientId,
val profileName: ProfileName,
val displayName: String?,
val photoUri: String?,
val label: String?,
val type: Int,
val contactUri: String
)
}

View File

@@ -1,17 +1,25 @@
package org.thoughtcrime.securesms.conversation
import android.os.Bundle
import androidx.core.view.WindowCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Activity which encapsulates a conversation for a Bubble window.
*
*8
* This activity exists so that we can override some of its manifest parameters
* without clashing with [ConversationActivity] and provide an API-level
* independent "is in bubble?" check.
*/
class BubbleConversationActivity : ConversationActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState, ready)
}
override fun onPause() {
super.onPause()
ViewUtil.hideKeyboard(this, findViewById(R.id.fragment_container))

View File

@@ -46,7 +46,7 @@ import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
@@ -113,7 +113,7 @@ public class ConversationAdapter
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private ColorizerV1 colorizer;
private boolean isTypingViewEnabled;
private ConversationItemDisplayMode displayMode;
private PulseRequest pulseRequest;
@@ -124,7 +124,7 @@ public class ConversationAdapter
@NonNull Locale locale,
@Nullable ItemClickListener clickListener,
boolean hasWallpaper,
@NonNull Colorizer colorizer)
@NonNull ColorizerV1 colorizer)
{
super(new DiffUtil.ItemCallback<ConversationMessage>() {
@Override

View File

@@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.fonts.SignalSymbols;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob;
@@ -147,6 +148,7 @@ import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ProjectionList;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.signal.core.ui.util.ThemeUtil;
import org.thoughtcrime.securesms.util.UrlClickHandler;
@@ -1093,7 +1095,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyText.setOverflowText(null);
bodyText.setMaxLength(-1);
if (messageRecord.isRemoteDelete()) {
if (RemoteConfig.receiveAdminDelete() && conversationMessage.getDeletedByRecipient() != null) {
bodyText.setText(getDeletedMessageText(conversationMessage));
bodyText.setVisibility(View.VISIBLE);
bodyText.setOverflowText(null);
} else if (messageRecord.isRemoteDelete()) {
String deletedMessage = context.getString(messageRecord.isOutgoing() ? R.string.ConversationItem_you_deleted_this_message : R.string.ConversationItem_this_message_was_deleted);
SpannableString italics = new SpannableString(deletedMessage);
italics.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
@@ -1156,6 +1162,44 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private SpannableStringBuilder getDeletedMessageText(@NonNull ConversationMessage message) {
boolean isAdminDelete = !message.getDeletedByRecipient().equals(message.getMessageRecord().getFromRecipient());
CharSequence body;
if (!isAdminDelete && messageRecord.isOutgoing()) {
body = formatDeletedText(context.getString(R.string.ConversationItem_you_deleted_this_message));
} else if (!isAdminDelete) {
body = formatDeletedText(context.getString(R.string.ConversationItem_s_deleted_this_message, message.getDeletedByRecipient().getDisplayName(context)));
} else {
SpannableString prefix = formatDeletedText(context.getString(R.string.ConversationItem_admin));
SpannableString suffix = formatDeletedText(context.getString(R.string.ConversationItem_deleted_this_message));
int nameColor = colorizer.getIncomingGroupSenderColor(getContext(), message.getDeletedByRecipient());
SpannableString name = new SpannableString(message.getDeletedByRecipient().getDisplayName(context));
name.setSpan(new ForegroundColorSpan(nameColor), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
name.setSpan(new RecipientClickableSpan(conversationMessage.getDeletedByRecipient().getId()), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
name.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
body = new SpannableStringBuilder()
.append(prefix)
.append(" ")
.append(name)
.append(" ")
.append(suffix);
}
return new SpannableStringBuilder()
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.X_CIRCLE, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant))
.append(" ")
.append(body);
}
private SpannableString formatDeletedText(String text) {
SpannableString spannableString = new SpannableString(text);
spannableString.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant)), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannableString;
}
private void setMediaAttributes(@NonNull MessageRecord messageRecord,
@NonNull Optional<MessageRecord> previousRecord,
@NonNull Optional<MessageRecord> nextRecord,
@@ -1668,7 +1712,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody);
for (Annotation annotation : mentionAnnotations) {
messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
messageBody.setSpan(new RecipientClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
@@ -2895,18 +2939,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private class MentionClickableSpan extends ClickableSpan {
private final RecipientId mentionedRecipientId;
private class RecipientClickableSpan extends ClickableSpan {
private final RecipientId recipientId;
MentionClickableSpan(RecipientId mentionedRecipientId) {
this.mentionedRecipientId = mentionedRecipientId;
RecipientClickableSpan(RecipientId recipientId) {
this.recipientId = recipientId;
}
@Override
public void onClick(@NonNull View widget) {
if (eventListener != null && batchSelected.isEmpty()) {
VibrateUtil.vibrateTick(context);
eventListener.onGroupMemberClicked(mentionedRecipientId, conversationRecipient.get().requireGroupId());
eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId());
}
}

View File

@@ -51,6 +51,7 @@ public class ConversationMessage {
@NonNull private final ComputedProperties computedProperties;
@Nullable private final MemberLabel memberLabel;
@Nullable private final MemberLabel quoteMemberLabel;
@Nullable private final Recipient deletedByRecipient;
private ConversationMessage(@NonNull MessageRecord messageRecord,
@Nullable CharSequence body,
@@ -61,7 +62,8 @@ public class ConversationMessage {
@Nullable MessageRecord originalMessage,
@NonNull ComputedProperties computedProperties,
@Nullable MemberLabel memberLabel,
@Nullable MemberLabel quoteMemberLabel)
@Nullable MemberLabel quoteMemberLabel,
@Nullable Recipient deletedByRecipient)
{
this.messageRecord = messageRecord;
this.hasBeenQuoted = hasBeenQuoted;
@@ -72,6 +74,7 @@ public class ConversationMessage {
this.computedProperties = computedProperties;
this.memberLabel = memberLabel;
this.quoteMemberLabel = quoteMemberLabel;
this.deletedByRecipient = deletedByRecipient;
if (body != null) {
this.body = SpannableString.valueOf(body);
@@ -116,6 +119,10 @@ public class ConversationMessage {
return quoteMemberLabel;
}
public @Nullable Recipient getDeletedByRecipient() {
return deletedByRecipient;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -252,6 +259,7 @@ public class ConversationMessage {
FormattedDate formattedDate = getFormattedDate(context, messageRecord);
MemberLabel memberLabel = getMemberLabel(messageRecord, threadRecipient);
MemberLabel quoteMemberLabel = getQuoteMemberLabel(messageRecord, threadRecipient);
Recipient deletedBy = messageRecord.getDeletedBy() != null ? Recipient.resolved(messageRecord.getDeletedBy()) : null;
return new ConversationMessage(messageRecord,
styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body,
@@ -262,7 +270,8 @@ public class ConversationMessage {
originalMessage,
new ComputedProperties(formattedDate),
memberLabel,
quoteMemberLabel);
quoteMemberLabel,
deletedBy);
}
/**

View File

@@ -119,7 +119,7 @@ private fun NewConversationScreen(
val coroutineScope = rememberCoroutineScope()
val callbacks = remember {
object : UiCallbacks {
object : NewConversationUiCallbacks {
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.createIntent(context))
override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME)
@@ -189,7 +189,7 @@ private suspend fun openConversation(
@Composable
private fun NewConversationScreenUi(
uiState: NewConversationUiState,
callbacks: UiCallbacks
callbacks: NewConversationUiCallbacks
) {
val snackbarHostState = remember { SnackbarHostState() }
@@ -221,7 +221,7 @@ private fun NewConversationScreenUi(
}
@Composable
private fun TopAppBarActions(callbacks: UiCallbacks) {
private fun TopAppBarActions(callbacks: NewConversationUiCallbacks) {
val menuController = remember { DropdownMenus.MenuController() }
IconButton(
onClick = { menuController.show() },
@@ -265,7 +265,7 @@ private fun TopAppBarActions(callbacks: UiCallbacks) {
}
}
private interface UiCallbacks :
private interface NewConversationUiCallbacks :
RecipientPickerCallbacks.ListActions,
RecipientPickerCallbacks.Refresh,
RecipientPickerCallbacks.ContextMenu,
@@ -278,7 +278,7 @@ private interface UiCallbacks :
fun onUserMessageDismissed(userMessage: UserMessage)
fun onBackPressed()
object Empty : UiCallbacks {
object Empty : NewConversationUiCallbacks {
override fun onSearchQueryChanged(query: String) = Unit
override fun onCreateNewGroup() = Unit
override fun onFindByUsername() = Unit
@@ -303,7 +303,7 @@ private interface UiCallbacks :
@Composable
private fun NewConversationRecipientPicker(
uiState: NewConversationUiState,
callbacks: UiCallbacks,
callbacks: NewConversationUiCallbacks,
modifier: Modifier = Modifier
) {
RecipientPicker(
@@ -400,7 +400,7 @@ private fun NewConversationScreenPreview() {
uiState = NewConversationUiState(
forceSplitPaneOnCompactLandscape = false
),
callbacks = UiCallbacks.Empty
callbacks = NewConversationUiCallbacks.Empty
)
}
}

View File

@@ -19,7 +19,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -74,7 +74,8 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
val conversationRecipient = Recipient.resolved(conversationRecipientId)
val colorizer = Colorizer()
@Suppress("DEPRECATION")
val colorizer = ColorizerV1()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
setCondensedMode(ConversationItemDisplayMode.Condensed(ConversationItemDisplayMode.MessageMode.PINNED))

View File

@@ -8,8 +8,11 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.DayOfWeek
import java.time.temporal.TemporalAdjusters
class ScheduleMessageContextMenu {
@@ -53,7 +56,7 @@ class ScheduleMessageContextMenu {
private fun getNextScheduleTimes(currentTimeMs: Long): List<Long> {
var currentDateTime = currentTimeMs.toLocalDateTime()
val timestampList = ArrayList<Long>(4)
val timestampList = ArrayList<Long>(5)
var presetIndex = presetHours.indexOfFirst { it > currentDateTime.hour }
if (presetIndex == -1) {
currentDateTime = currentDateTime.plusDays(1)
@@ -69,6 +72,18 @@ class ScheduleMessageContextMenu {
currentDateTime = currentDateTime.plusDays(1)
}
}
if (RemoteConfig.internalUser) {
val now = currentTimeMs.toLocalDateTime()
if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY) {
val nextMonday = now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
.withHour(8)
.withMinute(0)
.withSecond(0)
timestampList += nextMonday.toMillis()
}
}
timestampList += -1
return timestampList.reversed()

View File

@@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart.Attachments
@@ -47,7 +47,7 @@ import org.thoughtcrime.securesms.mms.TextSlide
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
@@ -88,9 +88,13 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
val conversationRecipient = Recipient.resolved(conversationRecipientId)
callback = requireListener()
callback = findListener<ConversationBottomSheetCallback>() ?: run {
dismissAllowingStateLoss()
return
}
val colorizer = Colorizer()
@Suppress("DEPRECATION")
val colorizer = ColorizerV1()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
setCondensedMode(ConversationItemDisplayMode.Condensed(ConversationItemDisplayMode.MessageMode.SCHEDULED))

View File

@@ -14,19 +14,28 @@ import androidx.fragment.app.FragmentManager;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import org.signal.core.models.ServiceId;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2;
import org.thoughtcrime.securesms.conversation.colors.NameColor;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel;
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelRepository;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.core.ui.BottomSheetUtil;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.WindowUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
@@ -68,7 +77,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
GroupMemberListView list = view.findViewById(R.id.show_admin_list);
list.initializeAdapter(getViewLifecycleOwner());
list.setDisplayOnlyMembers(Collections.emptyList());
list.setMembers(Collections.emptyList());
list.setRecipientClickListener(recipient -> {
CommunicationActions.startConversation(requireContext(), recipient, null);
@@ -78,7 +87,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
disposables.add(Single.fromCallable(() -> getAdmins(requireContext().getApplicationContext(), getGroupId()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(list::setDisplayOnlyMembers));
.subscribe(list::setMembers));
}
@Override
@@ -97,10 +106,23 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
}
@WorkerThread
private static @NonNull List<Recipient> getAdmins(@NonNull Context context, @NonNull GroupId groupId) {
return SignalDatabase.groups()
.getGroup(groupId)
.map(GroupRecord::getAdmins)
.orElse(Collections.emptyList());
private static @NonNull List<GroupMemberEntry> getAdmins(@NonNull Context context, @NonNull GroupId groupId) {
GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId).orElse(null);
if (groupRecord == null) {
return Collections.emptyList();
}
List<Recipient> admins = groupRecord.getAdmins();
Map<RecipientId, MemberLabel> labelsByRecipientId = MemberLabelRepository.getInstance().getLabelsJava(groupId.requireV2(), admins);
List<ServiceId> memberIds = groupRecord.requireV2GroupProperties().getMemberServiceIds();
ColorizerV2 colorizer = new ColorizerV2(memberIds);
List<GroupMemberEntry> result = new ArrayList<>();
for (Recipient admin : admins) {
MemberLabel label = labelsByRecipientId.get(admin.getId());
NameColor nameColor = label != null ? colorizer.getNameColor(context, admin) : null;
result.add(new GroupMemberEntry.FullMember(admin, true, label, nameColor));
}
return result;
}
}

View File

@@ -4,27 +4,18 @@ import android.content.Context
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import org.signal.core.models.ServiceId
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.signal.core.ui.R as CoreUiR
/**
* Helper class for all things ChatColors.
* Provides conversation bubble and sender name colors.
*
* - Maintains a mapping for group recipient colors
* - Gives easy access to different bubble colors
* - Watches and responds to RecyclerView scroll and layout changes to update a ColorizerView
* Use [ColorizerV2] for new CFv2 code, and [ColorizerV1] for legacy CFv1 code.
*/
class Colorizer {
private var colorsHaveBeenSet = false
@Deprecated("Not needed for CFv2")
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
private val groupMembers: LinkedHashSet<ServiceId> = linkedSetOf()
interface Colorizer {
@ColorInt
fun getOutgoingBodyTextColor(context: Context): Int {
return ContextCompat.getColor(context, R.color.conversation_outgoing_body_color)
@@ -67,46 +58,95 @@ class Colorizer {
}
}
@Suppress("DEPRECATION")
@ColorInt
fun getIncomingGroupSenderColor(context: Context, recipient: Recipient): Int {
return if (groupMembers.isEmpty()) {
groupSenderColors[recipient.id]?.getColor(context) ?: getDefaultColor(context, recipient)
} else if (recipient.hasServiceId) {
val memberPosition = groupMembers.indexOf(recipient.requireServiceId())
if (memberPosition >= 0) {
val colorPosition = memberPosition % ChatColorsPalette.Names.all.size
ChatColorsPalette.Names.all[colorPosition].getColor(context)
} else {
getDefaultColor(context, recipient)
}
} else {
getDefaultColor(context, recipient)
}
return getNameColor(context, recipient).getColor(context)
}
fun onGroupMembershipChanged(serviceIds: List<ServiceId>) {
groupMembers.addAll(serviceIds.sortedBy { it.toString() })
}
fun getNameColor(context: Context, recipient: Recipient): NameColor
}
@Suppress("DEPRECATION")
@Deprecated("Not needed for CFv2", ReplaceWith("onGroupMembershipChanged"))
/**
* [Colorizer] implementation for CFv1 (legacy ConversationFragment).
*
* Colors are pre-assigned via [onNameColorsChanged] using a static [RecipientId] → [NameColor] map.
*
* See [ColorizerV2] for the CFv2 position-based approach.
*/
@Deprecated("Use ColorizerV2 instead. This class only exists to support the legacy CFv1.")
class ColorizerV1 : Colorizer {
private var colorsHaveBeenSet = false
private val groupSenderColors: MutableMap<RecipientId, NameColor> = mutableMapOf()
/**
* Replaces the entire mapping of group member IDs to name colors.
*
* Must be called before [getNameColor] to ensure colors are assigned correctly.
*/
fun onNameColorsChanged(nameColorMap: Map<RecipientId, NameColor>) {
groupSenderColors.clear()
groupSenderColors.putAll(nameColorMap)
colorsHaveBeenSet = true
}
@Suppress("DEPRECATION")
@ColorInt
private fun getDefaultColor(context: Context, recipient: Recipient): Int {
return if (colorsHaveBeenSet) {
val color = ChatColorsPalette.Names.all[groupSenderColors.size % ChatColorsPalette.Names.all.size]
groupSenderColors[recipient.id] = color
return color.getColor(context)
} else {
getIncomingBodyTextColor(context, recipient.hasWallpaper)
/**
* Returns the name color for the given recipient based on their position in the group member list.
*/
override fun getNameColor(context: Context, recipient: Recipient): NameColor {
val assignedColor = groupSenderColors[recipient.id]
if (assignedColor != null) return assignedColor
if (colorsHaveBeenSet) {
return nameColorForPosition(groupSenderColors.size)
.also { groupSenderColors[recipient.id] = it }
}
val colorInt = getIncomingBodyTextColor(context, recipient.hasWallpaper)
return NameColor(lightColor = colorInt, darkColor = colorInt)
}
}
/**
* [Colorizer] implementation for CFv2 (ConversationFragment v2).
*
* Colors are derived from each member's sorted position in the group, populated via
* [onGroupMembershipChanged]. For the legacy CFv1 approach, see [ColorizerV1].
*/
class ColorizerV2 @JvmOverloads constructor(groupMemberIds: List<ServiceId> = emptyList()) : Colorizer {
private val groupMembers: LinkedHashSet<ServiceId> = linkedSetOf()
init {
onGroupMembershipChanged(groupMemberIds)
}
/**
* Replaces the entire set of group members used for position-based name color assignment.
*
* Must be called before [getNameColor] to ensure colors are assigned correctly.
*/
fun onGroupMembershipChanged(serviceIds: List<ServiceId>) {
groupMembers.addAll(serviceIds.sortedBy { it.toString() })
}
/**
* Returns the [NameColor] for the given [recipient] based on their sorted position among the
* other group members supplied via [onGroupMembershipChanged].
*
* Falls back to a default text color if the recipient has no service ID or is not
* found in the current membership set.
*/
override fun getNameColor(context: Context, recipient: Recipient): NameColor {
val serviceId = recipient.serviceId.orNull()
if (serviceId != null) {
val position = groupMembers.indexOf(serviceId)
if (position >= 0) return nameColorForPosition(position)
}
val colorInt = getIncomingBodyTextColor(context, recipient.hasWallpaper)
return NameColor(lightColor = colorInt, darkColor = colorInt)
}
}
private fun nameColorForPosition(position: Int): NameColor {
return ChatColorsPalette.Names.all[position % ChatColorsPalette.Names.all.size]
}

View File

@@ -7,9 +7,9 @@ import org.signal.core.ui.util.ThemeUtil
/**
* Class which stores information for a Recipient's name color in a group.
*/
class NameColor(
@ColorInt private val lightColor: Int,
@ColorInt private val darkColor: Int
data class NameColor(
@get:ColorInt private val lightColor: Int,
@get:ColorInt private val darkColor: Int
) {
@ColorInt
fun getColor(context: Context): Int {

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.DeliveryStatusView
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.conversation.colors.ColorizerView
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Projection
@@ -101,7 +102,7 @@ class ChatColorPreviewView @JvmOverloads constructor(
wallpaper = findViewById(R.id.wallpaper)
wallpaperDim = findViewById(R.id.wallpaper_dim)
colorizerView = findViewById(R.id.colorizer)
colorizer = Colorizer()
colorizer = ColorizerV2()
} finally {
typedArray?.recycle()
}

View File

@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.MessageId
@@ -74,7 +74,8 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
val conversationRecipient = Recipient.resolved(conversationRecipientId)
val colorizer = Colorizer()
@Suppress("DEPRECATION")
val colorizer = ColorizerV1()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply {
setCondensedMode(ConversationItemDisplayMode.Condensed(ConversationItemDisplayMode.MessageMode.STANDARD))

View File

@@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV1
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.quotes.OriginalMessageSeparatorDecoration
@@ -42,7 +42,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.ViewModelFactory
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.fragments.findListener
import java.util.Locale
/**
@@ -78,16 +78,20 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val callback = findListener<ConversationBottomSheetCallback>() ?: EmptyConversationBottomSheetCallback
disposables.bindTo(viewLifecycleOwner)
val colorizer = Colorizer()
@Suppress("DEPRECATION")
val colorizer = ColorizerV1()
val messageAdapter = ConversationAdapter(
requireContext(),
viewLifecycleOwner,
Glide.with(this),
Locale.getDefault(),
ConversationAdapterListener(),
ConversationAdapterListener(callback),
conversationRecipient.hasWallpaper,
colorizer
).apply {
@@ -142,7 +146,7 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
return callback
}
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by requireListener<ConversationBottomSheetCallback>().getConversationAdapterListener() {
private class ConversationAdapterListener(callback: ConversationBottomSheetCallback) : ConversationAdapter.ItemClickListener by callback.getConversationAdapterListener() {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.ui.edit
import android.net.Uri
import android.view.View
import androidx.lifecycle.Observer
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
/**
* Empty object for when a callback can't be found.
*/
object EmptyConversationAdapterListener : ConversationAdapter.ItemClickListener {
override fun onItemClick(item: MultiselectPart?) = Unit
override fun onItemLongClick(itemView: View?, item: MultiselectPart?) = Unit
override fun onQuoteClicked(messageRecord: MmsMessageRecord?) = Unit
override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) = Unit
override fun onStickerClicked(stickerLocator: StickerLocator) = Unit
override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) = Unit
override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) = Unit
override fun onAddToContactsClicked(contact: Contact) = Unit
override fun onMessageSharedContactClicked(choices: List<Recipient?>) = Unit
override fun onInviteSharedContactClicked(choices: List<Recipient?>) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onMessageWithErrorClicked(messageRecord: MessageRecord) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) = Unit
override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState?>) = Unit
override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState?>) = Unit
override fun onVoiceNotePause(uri: Uri) = Unit
override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) = Unit
override fun onVoiceNoteSeekTo(uri: Uri, position: Double) = Unit
override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onPlayInlineContent(conversationMessage: ConversationMessage?) = Unit
override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) = Unit
override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) = Unit
override fun onChangeNumberUpdateContact(recipient: Recipient) = Unit
override fun onChangeProfileNameUpdateContact(recipient: Recipient) = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onBlockJoinRequest(recipient: Recipient) = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onInviteToSignalClicked() = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit
override fun onUrlClicked(url: String): Boolean = false
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onGiftBadgeRevealed(messageRecord: MessageRecord) = Unit
override fun goToMediaPreview(parent: ConversationItem?, sharedElement: View?, args: MediaIntentFactory.MediaPreviewArgs?) = Unit
override fun onEditedIndicatorClicked(conversationMessage: ConversationMessage) = Unit
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
override fun onShowSafetyTips(forGroup: Boolean) = Unit
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
override fun onItemDoubleClick(multiselectPart: MultiselectPart?) = Unit
override fun onPaymentTombstoneClicked() = Unit
override fun onDisplayMediaNoLongerAvailableSheet() = Unit
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
override fun onUpdateSignalClicked() = Unit
override fun onViewResultsClicked(pollId: Long) = Unit
override fun onViewPollClicked(messageId: Long) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
override fun onViewPinnedMessage(messageId: Long) = Unit
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.ui.edit
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.model.MessageRecord
object EmptyConversationBottomSheetCallback : ConversationBottomSheetCallback {
override fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener = EmptyConversationAdapterListener
override fun jumpToMessage(messageRecord: MessageRecord) = Unit
override fun unpin(conversationMessage: ConversationMessage) = Unit
override fun copy(conversationMessage: ConversationMessage) = Unit
override fun delete(conversationMessage: ConversationMessage) = Unit
override fun save(conversationMessage: ConversationMessage) = Unit
}

View File

@@ -120,6 +120,7 @@ import org.signal.core.util.concurrent.addTo
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.core.util.requireParcelableCompat
import org.signal.core.util.setActionItemTint
import org.signal.donations.InAppPaymentType
import org.signal.ringrtc.CallLinkRootKey
@@ -209,7 +210,7 @@ import org.thoughtcrime.securesms.conversation.SelectedConversationModel
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository.ShareOrDraftData
@@ -256,6 +257,8 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelActivity
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
@@ -318,6 +321,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
@@ -534,7 +538,7 @@ class ConversationFragment :
}
private val conversationTooltips = ConversationTooltips(this)
private val colorizer = Colorizer()
private val colorizer = ColorizerV2()
private val textDraftSaveDebouncer = Debouncer(500)
private val doubleTapToEditDebouncer = DoubleClickDebouncer(200)
private val recentEmojis: RecentEmojiPageModel by lazy { RecentEmojiPageModel(AppDependencies.application, TextSecurePreferences.RECENT_STORAGE_KEY) }
@@ -637,8 +641,14 @@ class ConversationFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.isBackInvokedCallbackEnabled = false
binding.root.setUseWindowTypes(args.conversationScreenType == ConversationScreenType.NORMAL && !resources.getWindowSizeClass().isSplitPane())
if (args.conversationScreenType == ConversationScreenType.BUBBLE) {
binding.root.setNavigationBarInsetOverride(0)
view.post {
ViewCompat.requestApplyInsets(binding.root)
binding.root.requestLayout()
}
}
disposables.bindTo(viewLifecycleOwner)
@@ -679,6 +689,16 @@ class ConversationFragment :
container.fragmentManager = childFragmentManager
childFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java)
startActivity(MemberLabelActivity.createIntent(requireContext(), groupId))
}
childFragmentManager.setFragmentResultListener(AboutSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(AboutSheet.RESULT_GROUP_ID, GroupId.V2::class.java)
startActivity(MemberLabelActivity.createIntent(requireContext(), groupId))
}
ToolbarDependentMarginListener(binding.toolbar)
initializeMediaKeyboard()
@@ -997,9 +1017,13 @@ class ConversationFragment :
when {
state.isReactionDelegateShowing -> reactionDelegate.hide()
state.isSearchRequested -> searchMenuItem?.collapseActionView()
state.isInActionMode -> finishActionMode()
state.isMediaKeyboardShowing -> container.hideInput()
else -> {
// State has changed since the back handler was enabled. Let the back press proceed
// to the next handler by triggering onBackPressed again after setting a skip flag
@@ -1228,7 +1252,7 @@ class ConversationFragment :
groupJoinClickListener = conversationBannerListener::reviewJoinRequestsAction,
onSuggestionAddMembers = {
conversationGroupViewModel.groupRecordSnapshot?.let { groupRecord ->
GroupsV1MigrationSuggestionsDialog.show(requireActivity(), groupRecord.id.requireV2(), groupRecord.gv1MigrationSuggestions)
GroupsV1MigrationSuggestionsDialog.show(childFragmentManager, groupRecord.id.requireV2(), groupRecord.gv1MigrationSuggestions)
}
},
onSuggestionNoThanks = conversationGroupViewModel::onSuggestedMembersBannerDismissed,
@@ -1879,13 +1903,16 @@ class ConversationFragment :
when (data) {
is ShareOrDraftData.SendKeyboardImage -> sendMessageWithoutComposeInput(slide = data.slide, clearCompose = false)
is ShareOrDraftData.SendSticker -> sendMessageWithoutComposeInput(slide = data.slide, clearCompose = true)
is ShareOrDraftData.SetText -> {
composeText.setDraftText(data.text)
inputPanel.clickOnComposeInput()
}
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(data.location, MediaConstraints.getPushMediaConstraints())
is ShareOrDraftData.SetEditMessage -> {
composeText.setDraftText(data.draftText)
inputPanel.enterEditMessageMode(Glide.with(this), data.messageEdit, true, data.clearQuote)
@@ -2682,6 +2709,7 @@ class ConversationFragment :
.subscribeBy { result ->
when (result) {
is Result.Success -> Log.d(TAG, "$logMessage complete")
is Result.Failure -> {
Log.d(TAG, "$logMessage failed ${result.failure}")
toast(GroupErrors.getUserDisplayMessage(result.failure))
@@ -2821,7 +2849,10 @@ class ConversationFragment :
disposables += DeleteDialog.show(
context = requireContext(),
messageRecords = records
messageRecords = records,
title = requireContext().resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_title, records.size, records.size),
message = requireContext().resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_body, records.size, records.size),
isAdmin = conversationGroupViewModel.isAdmin()
).observeOn(AndroidSchedulers.mainThread())
.subscribe { (deleted: Boolean, _: Boolean) ->
if (!deleted) return@subscribe
@@ -3476,14 +3507,12 @@ class ConversationFragment :
}
override fun onCallToAction(action: String) {
if ("gift_badge" == action) {
checkoutLauncher.launch(InAppPaymentType.ONE_TIME_GIFT)
} else if ("username_edit" == action) {
startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
} else if ("calls_tab" == action) {
startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainNavigationListLocation.CALLS))
} else if ("chat_folder" == action) {
startActivity(AppSettingsActivity.chatFolders(requireContext()))
when (action) {
"gift_badge" -> checkoutLauncher.launch(InAppPaymentType.ONE_TIME_GIFT)
"username_edit" -> startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
"calls_tab" -> startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainNavigationListLocation.CALLS))
"chat_folder" -> startActivity(AppSettingsActivity.chatFolders(requireContext()))
"remote_backups" -> startActivity(AppSettingsActivity.remoteBackups(requireContext()))
}
}
@@ -3984,7 +4013,7 @@ class ConversationFragment :
}
override fun handleMuteNotifications() {
MuteDialog.show(requireContext(), viewModel::muteConversation)
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner, viewModel::muteConversation)
}
override fun handleUnmuteNotifications() {
@@ -4148,8 +4177,11 @@ class ConversationFragment :
val slides: List<Slide> = result.nonUploadedMedia.mapNotNull {
when {
MediaUtil.isVideoType(it.contentType) -> VideoSlide(requireContext(), it.uri, it.size, it.isVideoGif, it.width, it.height, it.caption, it.transformProperties)
MediaUtil.isGif(it.contentType) -> GifSlide(requireContext(), it.uri, it.size, it.width, it.height, it.isBorderless, it.caption)
MediaUtil.isImageType(it.contentType) -> ImageSlide(requireContext(), it.uri, it.contentType, it.size, it.width, it.height, it.isBorderless, it.caption, null, it.transformProperties)
MediaUtil.isDocumentType(it.contentType) -> {
DocumentSlide(requireContext(), it.uri, it.contentType!!, it.size, it.fileName)
}
@@ -4261,7 +4293,7 @@ class ConversationFragment :
override fun gv1SuggestionsAction(actionId: Int) {
if (actionId == R.id.reminder_action_gv1_suggestion_add_members) {
conversationGroupViewModel.groupRecordSnapshot?.let { groupRecord ->
GroupsV1MigrationSuggestionsDialog.show(requireActivity(), groupRecord.id.requireV2(), groupRecord.gv1MigrationSuggestions)
GroupsV1MigrationSuggestionsDialog.show(childFragmentManager, groupRecord.id.requireV2(), groupRecord.gv1MigrationSuggestions)
}
} else if (actionId == R.id.reminder_action_gv1_suggestion_no_thanks) {
conversationGroupViewModel.onSuggestedMembersBannerDismissed()
@@ -4351,6 +4383,7 @@ class ConversationFragment :
.subscribeBy { result ->
when (result) {
is Result.Success -> Log.d(TAG, "Cancel request complete")
is Result.Failure -> {
Log.d(TAG, "Cancel join request failed ${result.failure}")
toast(GroupErrors.getUserDisplayMessage(result.failure))
@@ -4729,9 +4762,13 @@ class ConversationFragment :
if (button != null) {
when (button) {
AttachmentKeyboardButton.GALLERY -> conversationActivityResultContracts.launchGallery(recipient.id, composeText.textTrimmed, inputPanel.quote.isPresent)
AttachmentKeyboardButton.CONTACT -> conversationActivityResultContracts.launchSelectContact()
AttachmentKeyboardButton.LOCATION -> conversationActivityResultContracts.launchSelectLocation(recipient.chatColors)
AttachmentKeyboardButton.PAYMENT -> AttachmentManager.selectPayment(this@ConversationFragment, recipient)
AttachmentKeyboardButton.FILE -> {
if (!conversationActivityResultContracts.launchSelectFile()) {
toast(R.string.AttachmentManager_cant_open_media_selection, Toast.LENGTH_LONG)

View File

@@ -146,7 +146,7 @@ private fun CreatePollScreen(
// Parts of poll
var question by remember { mutableStateOf("") }
val options = remember { mutableStateListOf("", "") }
var allowMultiple by remember { mutableStateOf(false) }
var allowMultiple by remember { mutableStateOf(true) }
var hasMinimumOptions by remember { mutableStateOf(false) }
val isEnabled = question.isNotBlank() && hasMinimumOptions

View File

@@ -62,6 +62,10 @@ class ConversationGroupViewModel(
disposables.clear()
}
fun isAdmin(): Boolean {
return _memberLevel.value?.groupTableMemberLevel == GroupTable.MemberLevel.ADMINISTRATOR
}
fun isNonAdminInAnnouncementGroup(): Boolean {
val memberLevel = _memberLevel.value ?: return false
return memberLevel.groupTableMemberLevel != GroupTable.MemberLevel.ADMINISTRATOR && memberLevel.isAnnouncementGroup

View File

@@ -492,7 +492,6 @@ public class ConversationListFragment extends MainFragment implements Conversati
initializeSearchListener();
initializeFilterListener();
itemAnimator.disable();
SpoilerAnnotation.resetRevealedSpoilers();
if (mainToolbarViewModel.getState().getValue().getMode() != MainToolbarMode.SEARCH && list.getAdapter() != defaultAdapter) {
@@ -550,7 +549,6 @@ public class ConversationListFragment extends MainFragment implements Conversati
public void onStart() {
super.onStart();
AppForegroundObserver.addListener(appForegroundObserver);
itemAnimator.disable();
}
@Override
@@ -1179,9 +1177,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
private void handleMute(@NonNull Collection<Conversation> conversations) {
MuteDialog.show(requireContext(), until -> {
updateMute(conversations, until);
});
MuteDialog.show(requireContext(), getChildFragmentManager(), getViewLifecycleOwner(), until -> updateMute(conversations, until));
}
private void handleUnmute(@NonNull Collection<Conversation> conversations) {
@@ -1576,6 +1572,34 @@ public class ConversationListFragment extends MainFragment implements Conversati
chatFolderList.getLayoutManager().startSmoothScroll(smoothScroller);
}
// Manage change animations so we don't animate the list when switching folders
itemAnimator.disableChangeAnimations();
defaultAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
@Override
public void onChanged() {
defaultAdapter.unregisterAdapterDataObserver(this);
itemAnimator.enableChangeAnimations();
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
defaultAdapter.unregisterAdapterDataObserver(this);
itemAnimator.enableChangeAnimations();
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount) {
defaultAdapter.unregisterAdapterDataObserver(this);
itemAnimator.enableChangeAnimations();
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
defaultAdapter.unregisterAdapterDataObserver(this);
itemAnimator.enableChangeAnimations();
}
});
viewModel.select(chatFolder);
}
@@ -1586,7 +1610,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
@Override
public void onMuteAll(@NonNull ChatFolderRecord chatFolder) {
MuteDialog.show(requireContext(), until -> viewModel.onUpdateMute(chatFolder, until));
MuteDialog.show(requireContext(), getChildFragmentManager(), getViewLifecycleOwner(), until -> viewModel.onUpdateMute(chatFolder, until));
}
@Override
@@ -1919,6 +1943,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
void onMultiSelectFinished();
}
}

View File

@@ -534,7 +534,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
} else {
alertView.setNone();
if (thread.getExtra() != null && thread.getExtra().isRemoteDelete()) {
if (thread.getExtra() != null && thread.getExtra().getDeletedBy() != null) {
if (thread.isPending()) {
deliveryStatusIndicator.setPending();
} else {
@@ -697,8 +697,16 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
ThreadTable.Extra extra = thread.getExtra();
if (extra != null && extra.isViewOnce()) {
return emphasisAdded(context, getViewOnceDescription(context, thread.getContentType()), defaultTint);
} else if (extra != null && extra.isRemoteDelete()) {
return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint);
} else if (extra != null && extra.getDeletedBy() != null) {
RecipientId individualRecipientId = thread.getIndividualRecipientId();
RecipientId deletedBy = thread.getDeletedByRecipientId();
if (individualRecipientId.equals(deletedBy) && thread.isOutgoing()) {
return emphasisAdded(context, context.getString(R.string.ThreadRecord_you_deleted_this_message), defaultTint);
} else if (individualRecipientId.equals(deletedBy)) {
return emphasisAdded(recipientToStringAsync(deletedBy, r -> new SpannableString(context.getString(R.string.ThreadRecord_s_deleted_this_message, r.getDisplayName(context)))));
} else {
return emphasisAdded(recipientToStringAsync(deletedBy, r -> new SpannableString(context.getString(R.string.ThreadRecord_admin_deleted_this_message, r.getDisplayName(context)))));
}
} else if (extra != null && extra.isPoll()) {
return emphasisAdded(context, thread.getBody(), Glyph.POLL, defaultTint);
} else {

View File

@@ -16,9 +16,9 @@ public class ConversationListItemAnimator extends DefaultItemAnimator {
private boolean shouldDisable;
public ConversationListItemAnimator() {
setSupportsChangeAnimations(false);
setMoveDuration(0);
setAddDuration(0);
setChangeDuration(ANIMATION_DURATION);
}
@MainThread
@@ -32,6 +32,16 @@ public class ConversationListItemAnimator extends DefaultItemAnimator {
setMoveDuration(0);
}
@MainThread
public void disableChangeAnimations() {
setChangeDuration(0);
}
@MainThread
public void enableChangeAnimations() {
setChangeDuration(ANIMATION_DURATION);
}
/**
* We need to reasonably ensure that the animation has started before we disable things here, so we add a slight delay.

View File

@@ -2099,12 +2099,15 @@ class AttachmentTable(
val contentValues = contentValuesOf(
DATA_SIZE to newDataFileInfo.length,
CONTENT_TYPE to mediaStream.mimeType,
WIDTH to mediaStream.width,
HEIGHT to mediaStream.height,
DATA_FILE to newDataFileInfo.file.absolutePath,
DATA_RANDOM to newDataFileInfo.random
)
if (mediaStream.width > 0 && mediaStream.height > 0) {
contentValues.put(WIDTH, mediaStream.width)
contentValues.put(HEIGHT, mediaStream.height)
}
val updateCount = db.update(TABLE_NAME)
.values(contentValues)
.where("$ID = ? OR $DATA_FILE = ?", attachmentId.id, existingDataFileInfo.file.absolutePath)

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