Compare commits

...

54 Commits

Author SHA1 Message Date
jeffrey-signal f8737995fa Bump version to 8.2.2 2026-03-09 12:17:17 -04:00
jeffrey-signal 1bbefea857 Update baseline profile. 2026-03-09 12:13:20 -04:00
jeffrey-signal 143630c41b Update translations and other static files. 2026-03-09 12:05:25 -04:00
Michelle Tang 577eaa1eae Avoid dropping column in message table. 2026-03-09 10:45:27 -04:00
Greyson Parrelli 316b071c81 Bump version to 8.2.1 2026-03-06 16:34:51 -05:00
Greyson Parrelli 5a6f55c0a8 Update baseline profile. 2026-03-06 16:34:32 -05:00
Greyson Parrelli e008a50acc Update translations and other static files. 2026-03-06 16:18:53 -05:00
Michelle Tang 41c3913482 Update notification on admin delete. 2026-03-06 13:39:53 -05:00
Greyson Parrelli 803ff76678 Bump version to 8.2.0 2026-03-04 14:15:38 -05:00
Greyson Parrelli 309081437a Update baseline profile. 2026-03-04 14:15:38 -05:00
Greyson Parrelli 5f152b73c2 Update translations and other static files. 2026-03-04 14:03:32 -05:00
Greyson Parrelli f8d3336a1e Add internal setting to disable internal user. 2026-03-04 13:55:39 -05:00
jeffrey-signal dc1fdffe6a Warn user when their member label will show instead of their about text. 2026-03-04 13:55:39 -05:00
Greyson Parrelli 622d9c909f Fix unarchive actions from conversation search.
Fixes #14640
2026-03-04 13:55:39 -05:00
Michelle Tang 4e3ef19c1f Rotate receive for admin delete. 2026-03-04 13:55:39 -05:00
Greyson Parrelli b054a30fa7 Add support for remote muting call participants. 2026-03-04 13:55:39 -05:00
jeffrey-signal 7266c24354 Show the entire member label on recipient details sheet. 2026-03-04 13:55:39 -05:00
jeffrey-signal 5ec2877bcc Fix member label disappearing after a new group member is added. 2026-03-04 13:55:39 -05:00
jeffrey-signal 0d93446c7d Fix member label emoji picker button not respecting use system emoji preference. 2026-03-04 13:55:39 -05:00
Michelle Tang 1e395ab416 Use global config for admin delete timer. 2026-03-04 13:55:39 -05:00
Michelle Tang 0acb5ac7cd Update admin delete string. 2026-03-04 13:55:39 -05:00
jeffrey-signal 3b18b5d2b7 Fix member label text size. 2026-03-04 13:55:39 -05:00
jeffrey-signal 16e63a061d Allow any group member to set member labels. 2026-03-04 13:55:39 -05:00
Michelle Tang a6c8b940c9 Consolidate admin delete into one string. 2026-03-04 13:55:39 -05:00
Michelle Tang 74d9e3248b Add pending and failed states for admin delete. 2026-03-04 13:55:39 -05:00
andrew-signal 3af8b6050c Bump to libsignal v0.88.0. 2026-03-04 13:55:39 -05:00
Greyson Parrelli da966753a1 Guard against invalid authors in directionless messages in archive export. 2026-03-04 13:55:39 -05:00
Cody Henthorne 0ad4b3f73e Skip optimize media when backup subscription is pending cancelation. 2026-03-04 13:55:39 -05:00
Greyson Parrelli e8d072d4be Only set optimized storage in archive if on paid tier. 2026-03-04 13:55:39 -05:00
Greyson Parrelli b0eed4a095 Filter out unmappable body ranges during archive export. 2026-03-04 13:55:39 -05:00
Greyson Parrelli ba720efe61 Skip quotes with authors that lack ACI and E164 during archive export. 2026-03-04 13:55:39 -05:00
Cody Henthorne e23d575460 Fix incorrect transaction batching during conversation delete. 2026-03-04 13:55:39 -05:00
Greyson Parrelli 7fbcd17759 Add some megaphones to encourage users to try backups. 2026-03-04 13:55:39 -05:00
Greyson Parrelli a95ebb2158 Add improved notification settings when muted. 2026-03-04 13:55:39 -05:00
Greyson Parrelli 8a36425cac Remove broken legacy color migration.
Fixes #14228
Resolves #14518
2026-03-04 13:45:24 -05:00
Michelle Tang 4261ed39dc Fix message details crash on recipient tap. 2026-03-04 13:45:24 -05:00
jeffrey-signal ca37a884fd Delete unused GroupMembersDialog. 2026-03-04 13:45:24 -05:00
Alex Hart 9fbb7683bc Fix RTL text direction not enforced when text starts with LTR characters.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-04 13:45:24 -05:00
Jim Gustafson 42e275ef0a Update to RingRTC v2.65.3 2026-03-04 13:45:24 -05:00
Greyson Parrelli 19ece12e93 Remove deprecated backup flavor. 2026-03-04 13:45:24 -05:00
Michelle Tang 3ef0d3e4a3 Skip pins of deleted messages. 2026-03-04 13:45:24 -05:00
andrew-signal 602ea46b8b Bump to libsignal v0.87.5. 2026-03-04 13:45:24 -05:00
Alex Hart 95c0bc6052 Update internal and local backup access. 2026-03-04 13:45:24 -05:00
Alex Hart bd4ce1788c Fix ANR when backup deletion hangs. 2026-03-04 13:45:24 -05:00
Alex Hart 20d16a8433 Show immediate progress feedback when creating a local backup.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-04 13:45:24 -05:00
Alex Hart db4c11cd53 Use user-friendly display path for local backup folder.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-04 13:45:24 -05:00
Greyson Parrelli f439e1f8e3 Add additional upload validation to UploadAttachmentToArchiveJob. 2026-03-04 13:45:24 -05:00
Cody Henthorne 080b1aab83 Fix unable to restore username after device transfer. 2026-03-04 13:45:24 -05:00
Cody Henthorne 61ba2ac97a Improve message processing performance. 2026-03-04 13:45:23 -05:00
Alex Hart 7eebb38eda Add post-registration restore for backups v2 as well as error messaging. 2026-03-04 13:45:23 -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
281 changed files with 18233 additions and 8645 deletions
+2 -15
View File
@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1659
val canonicalVersionName = "8.1.2"
val canonicalVersionCode = 1663
val canonicalVersionName = "8.2.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -53,8 +53,6 @@ val localProperties: Properties? = if (localPropertiesFile.exists()) {
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
val selectableVariants = listOf(
"nightlyBackupRelease",
"nightlyBackupSpinner",
"nightlyProdSpinner",
"nightlyProdPerf",
"nightlyProdRelease",
@@ -468,17 +466,6 @@ android {
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
}
create("backup") {
initWith(getByName("staging"))
dimension = "environment"
applicationIdSuffix = ".backup"
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
}
}
lint {
@@ -53,8 +53,15 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
"group-delivery-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundDeliveryReceipts(timestamps) }
"group-read-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundReadReceipts(timestamps) }
"release-messages" -> {
BenchmarkWebSocketConnection.authInstance.startWholeBatchTrace = true
BenchmarkWebSocketConnection.authInstance.releaseMessages()
BenchmarkWebSocketConnection.startWholeBatchTrace()
BenchmarkWebSocketConnection.releaseMessages()
}
"delete-thread" -> {
val pendingResult = goAsync()
Thread {
handleDeleteThread()
pendingResult.finish()
}.start()
}
else -> Log.w(TAG, "Unknown command: $command")
}
@@ -68,25 +75,23 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial message form Bob to establish session.")
addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
releaseMessages()
Log.i(TAG, "Sending initial message form Bob to establish session.")
BenchmarkWebSocketConnection.addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
BenchmarkWebSocketConnection.releaseMessages()
// Sleep briefly to let the message be processed.
ThreadUtil.sleep(100)
}
// Sleep briefly to let the message be processed.
ThreadUtil.sleep(1000)
}
}
// Have Bob generate N messages that will be received by Alice
val messageCount = 100
val messageCount = 500
val envelopes = client.generateInboundEnvelopes(messageCount)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
BenchmarkWebSocketConnection.addPendingMessages(messages)
BenchmarkWebSocketConnection.addQueueEmptyMessage()
}
private fun handlePrepareGroupSend() {
@@ -97,27 +102,24 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
releaseMessages()
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.releaseMessages()
// Sleep briefly to let the messages be processed.
ThreadUtil.sleep(1000)
}
// Sleep briefly to let the messages be processed.
ThreadUtil.sleep(1000)
}
}
// Have clients generate N group messages that will be received by Alice
clients.forEach { client ->
val allClientMessages = clients.map { client ->
val messageCount = 100
val envelopes = client.generateInboundGroupEnvelopes(messageCount, Harness.groupMasterKey)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
envelopes.map { e -> e.toWebSocketPayload() }
}
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
BenchmarkWebSocketConnection.addPendingMessages(interleave(allClientMessages))
BenchmarkWebSocketConnection.addQueueEmptyMessage()
}
private fun handlePrepareGroupReceipts(generateReceipts: (OtherClient, List<Long>) -> List<Envelope>) {
@@ -132,8 +134,8 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
generateReceipts(client, timestamps).map { it.toWebSocketPayload() }
}
BenchmarkWebSocketConnection.authInstance.addPendingMessages(interleave(allClientEnvelopes))
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
BenchmarkWebSocketConnection.addPendingMessages(interleave(allClientEnvelopes))
BenchmarkWebSocketConnection.addQueueEmptyMessage()
}
private fun establishGroupSessions(clients: List<OtherClient>) {
@@ -141,16 +143,28 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
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)
}
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.releaseMessages()
ThreadUtil.sleep(1000)
}
}
}
private fun handleDeleteThread() {
val threadId = SignalDatabase.threads.getRecentConversationList(1, false, false).use { cursor ->
if (cursor.moveToFirst()) {
cursor.getLong(cursor.getColumnIndexOrThrow("_id"))
} else {
Log.w(TAG, "No active threads found for deletion benchmark")
return
}
}
Log.i(TAG, "Deleting thread $threadId")
SignalDatabase.threads.deleteConversation(threadId, syncThreadDelete = false)
Log.i(TAG, "Thread $threadId deleted")
}
private fun getOutgoingGroupMessageTimestamps(): List<Long> {
val groupId = GroupId.v2(Harness.groupMasterKey)
val groupRecipient = Recipient.externalGroupExact(groupId)
@@ -1,7 +1,15 @@
package org.signal.benchmark
import android.os.Bundle
import android.widget.TextView
import androidx.activity.compose.setContent
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.signal.benchmark.setup.TestMessages
import org.signal.benchmark.setup.TestUsers
import org.thoughtcrime.securesms.BaseActivity
@@ -15,19 +23,29 @@ class BenchmarkSetupActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
var setupComplete by mutableStateOf(false)
setContent {
if (setupComplete) {
Text("done")
} else {
CircularProgressIndicator()
}
}
val textView: TextView = TextView(this).apply {
text = "done"
lifecycleScope.launch(Dispatchers.IO) {
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
"thread-delete" -> setupThreadDelete()
"thread-delete-group" -> setupThreadDeleteGroup()
}
setupComplete = true
}
setContentView(textView)
}
private fun setupColdStart() {
@@ -74,6 +92,65 @@ class BenchmarkSetupActivity : BaseActivity() {
TestUsers.setupGroup()
}
private fun setupThreadDelete() {
TestUsers.setupSelf()
val recipientIds = TestUsers.setupTestRecipients(2)
val recipient = Recipient.resolved(recipientIds[0])
val reactionAuthor = recipientIds[1]
val messagesToAdd = 20_000
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
for (i in 0 until messagesToAdd) {
val timestamp = generator.nextTimestamp()
when {
i % 20 == 0 -> TestMessages.insertIncomingVoiceMessage(other = recipient, timestamp = timestamp)
i % 4 == 0 -> TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 1, timestamp = timestamp)
else -> TestMessages.insertIncomingTextMessage(other = recipient, body = "Message $i", timestamp = timestamp)
}
}
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient)
TestDbUtils.insertReactionsForThread(threadId, reactionAuthor, moduloFilter = 5)
SignalDatabase.threads.update(threadId, true)
}
private fun setupThreadDeleteGroup() {
TestUsers.setupSelf()
val groupId = TestUsers.setupGroup()
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val selfId = Recipient.self().id
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
val messagesToAdd = 20_000
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
for (i in 0 until messagesToAdd) {
val timestamp = generator.nextTimestamp()
when {
i % 4 == 0 -> TestMessages.insertOutgoingImageMessage(other = groupRecipient, attachmentCount = 1, timestamp = timestamp)
else -> {
val message = OutgoingMessage(
recipient = groupRecipient,
body = "Message $i",
timestamp = timestamp,
isSecure = true
)
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
SignalDatabase.messages.markAsSent(insert.messageId, true)
}
}
}
TestDbUtils.insertGroupReceiptsForThread(threadId, memberRecipientIds)
TestDbUtils.insertReactionsForThread(threadId, memberRecipientIds[0], moduloFilter = 5)
TestDbUtils.insertMentionsForThread(threadId, memberRecipientIds[0], moduloFilter = 10)
SignalDatabase.threads.update(threadId, true)
}
private fun setupGroupReceipt(includeMsl: Boolean = false, enableReadReceipts: Boolean = false) {
TestUsers.setupSelf()
val groupId = TestUsers.setupGroup()
@@ -14,6 +14,62 @@ object TestDbUtils {
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
}
/**
* Bulk-inserts a reaction on every Nth message (by _id modulo) in the given thread.
*/
fun insertReactionsForThread(threadId: Long, authorId: RecipientId, moduloFilter: Int) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
db.execSQL(
"""
INSERT INTO reaction (message_id, author_id, emoji, date_sent, date_received)
SELECT ${MessageTable.ID}, ?, '👍', ${MessageTable.DATE_SENT}, ${MessageTable.DATE_RECEIVED}
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
""".trimIndent(),
arrayOf(authorId.toLong().toString(), threadId.toString(), moduloFilter.toString())
)
}
/**
* Bulk-inserts group receipt rows for every message in the given thread, one row per member.
*/
fun insertGroupReceiptsForThread(threadId: Long, memberRecipientIds: List<RecipientId>) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
db.beginTransaction()
try {
for (recipientId in memberRecipientIds) {
db.execSQL(
"""
INSERT INTO group_receipts (mms_id, address, status, timestamp)
SELECT ${MessageTable.ID}, ?, 2, ${MessageTable.DATE_SENT}
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.THREAD_ID} = ?
""".trimIndent(),
arrayOf(recipientId.toLong().toString(), threadId.toString())
)
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
/**
* Bulk-inserts a mention on every Nth message (by _id modulo) in the given thread.
*/
fun insertMentionsForThread(threadId: Long, mentionedRecipientId: RecipientId, moduloFilter: Int) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
db.execSQL(
"""
INSERT INTO mention (thread_id, message_id, recipient_id, range_start, range_length)
SELECT ${MessageTable.THREAD_ID}, ${MessageTable.ID}, ?, 0, 5
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
""".trimIndent(),
arrayOf(mentionedRecipientId.toLong().toString(), threadId.toString(), moduloFilter.toString())
)
}
fun getOutgoingMessageTimestamps(threadId: Long, selfRecipientId: Long): List<Long> {
val timestamps = mutableListOf<Long>()
SignalDatabase.messages.databaseHelper.signalReadableDatabase.query(
@@ -29,23 +29,42 @@ import java.util.concurrent.TimeoutException
class BenchmarkWebSocketConnection : WebSocketConnection {
companion object {
lateinit var authInstance: BenchmarkWebSocketConnection
private set
private val authInstances = mutableListOf<BenchmarkWebSocketConnection>()
private val unauthInstances = mutableListOf<BenchmarkWebSocketConnection>()
@Synchronized
fun createAuthInstance(): WebSocketConnection {
authInstance = BenchmarkWebSocketConnection()
val authInstance = BenchmarkWebSocketConnection()
authInstances += authInstance
return authInstance
}
lateinit var unauthInstance: BenchmarkWebSocketConnection
private set
@Synchronized
fun createUnauthInstance(): WebSocketConnection {
unauthInstance = BenchmarkWebSocketConnection()
val unauthInstance = BenchmarkWebSocketConnection()
unauthInstances += unauthInstance
return unauthInstance
}
@Synchronized
fun startWholeBatchTrace() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.startWholeBatchTrace = true }
}
@Synchronized
fun releaseMessages() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.releaseMessages() }
}
@Synchronized
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addPendingMessages(messages) }
}
@Synchronized
fun addQueueEmptyMessage() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
}
}
override val name: String = "bench-${System.identityHashCode(this)}"
@@ -58,7 +77,8 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
var startWholeBatchTrace = false
@Volatile
private var isShutdown = false
var isShutdown = false
private set
override fun connect(): Observable<WebSocketConnectionState> {
state.onNext(WebSocketConnectionState.CONNECTED)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,59 +0,0 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import java.util.List;
public final class GroupMembersDialog {
private final FragmentActivity fragmentActivity;
private final Recipient groupRecipient;
public GroupMembersDialog(@NonNull FragmentActivity activity,
@NonNull Recipient groupRecipient)
{
this.fragmentActivity = activity;
this.groupRecipient = groupRecipient;
}
public void display() {
AlertDialog dialog = new MaterialAlertDialogBuilder(fragmentActivity)
.setTitle(R.string.ConversationActivity_group_members)
.setIcon(R.drawable.ic_group_24)
.setCancelable(true)
.setView(R.layout.dialog_group_members)
.setPositiveButton(android.R.string.ok, null)
.show();
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
memberListView.initializeAdapter(fragmentActivity);
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
LiveData<List<GroupMemberEntry.FullMember>> fullMembers = liveGroup.getFullMembers();
//noinspection ConstantConditions
fullMembers.observe(fragmentActivity, memberListView::setMembers);
dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity));
memberListView.setRecipientClickListener(recipient -> {
dialog.dismiss();
contactClick(recipient);
});
}
private void contactClick(@NonNull Recipient recipient) {
RecipientBottomSheetDialogFragment.show(fragmentActivity.getSupportFragmentManager(), recipient.getId(), groupRecipient.requireGroupId());
}
}
@@ -155,6 +155,10 @@ object ExportSkips {
return log(sentTimestamp, "An incoming message author did not have an aci or e164.")
}
fun directionlessMessageAuthorDoesNotHaveAciOrE164(sentTimestamp: Long): String {
return log(sentTimestamp, "A directionlessmessage author did not have an aci or e164.")
}
fun outgoingMessageToReleaseNotesChat(sentTimestamp: Long): String {
return log(sentTimestamp, "An outgoing message was sent to the release notes chat.")
}
@@ -218,6 +222,10 @@ object ExportOddities {
return log(sentTimestamp, "Quote author was not found in the exported recipients. Removing the quote.")
}
fun quoteAuthorHasNoAciOrE164(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote author has neither an ACI nor an E164. Removing the quote.")
}
fun emptyQuote(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
}
@@ -62,7 +62,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
style = ChatStyleConverter.constructRemoteChatStyle(
db = db,
chatColors = chatColors,
@@ -1179,6 +1179,11 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
return null
}
if (exportState.recipientIdToAci[this.quoteAuthor] == null && exportState.recipientIdToE164[this.quoteAuthor] == null) {
Log.w(TAG, ExportOddities.quoteAuthorHasNoAciOrE164(this.dateSent))
return null
}
val localType = QuoteModel.Type.fromCode(this.quoteType)
val remoteType = when (localType) {
QuoteModel.Type.NORMAL -> {
@@ -1388,16 +1393,16 @@ private fun ByteArray.toRemoteBodyRanges(dateSent: Long): List<BackupBodyRange>
return emptyList()
}
return decoded.ranges.map { range ->
return decoded.ranges.mapNotNull { range ->
val mention = range.mentionUuid?.let { UuidUtil.parseOrNull(it) }?.toByteArray()?.toByteString()?.takeIf { it.isNotEmpty() }
val style = if (mention == null) {
range.style?.toRemote() ?: BackupBodyRange.Style.NONE
range.style?.toRemote()
} else {
null
}
if (mention == null && style == null) {
return emptyList()
return@mapNotNull null
}
BackupBodyRange(
@@ -1700,6 +1705,11 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
return null
}
if (this.directionless != null && this.authorId != selfRecipientId.toLong() && exportState.recipientIdToAci[this.authorId] == null && exportState.recipientIdToE164[this.authorId] == null) {
Log.w(TAG, ExportSkips.directionlessMessageAuthorDoesNotHaveAciOrE164(this.dateSent))
return null
}
if (this.outgoing != null && exportState.releaseNoteRecipientId != null && exportState.threadIdToRecipientId[this.chatId] == exportState.releaseNoteRecipientId) {
Log.w(TAG, ExportSkips.outgoingMessageToReleaseNotesChat(this.dateSent))
return null
@@ -60,7 +60,7 @@ object ChatArchiveImporter {
.update(
RecipientTable.TABLE_NAME,
contentValuesOf(
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id else RecipientTable.NotificationSetting.ALWAYS_NOTIFY.id),
RecipientTable.MUTE_UNTIL to (chat.muteUntilMs ?: 0),
RecipientTable.MESSAGE_EXPIRATION_TIME to (chat.expirationTimerMs?.milliseconds?.inWholeSeconds ?: 0),
RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION to chat.expireTimerVersion,
@@ -125,7 +125,7 @@ object AccountDataArchiveProcessor {
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage,
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
autoDownloadSettings = AccountData.AutoDownloadSettings(
@@ -0,0 +1,127 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.signal.core.ui.R as CoreUiR
/**
* Bottom sheet shown after a successful paid backup subscription from a storage upsell megaphone.
* Allows the user to start their first backup and optionally enable storage optimization.
*/
class BackupSetupCompleteBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.75f
@Composable
override fun SheetContent() {
SetupCompleteSheetContent(
onBackUpNowClick = { optimizeStorage ->
SignalStore.backup.optimizeStorage = optimizeStorage
BackupMessagesJob.enqueue()
dismissAllowingStateLoss()
}
)
}
}
@Composable
private fun SetupCompleteSheetContent(
onBackUpNowClick: (optimizeStorage: Boolean) -> Unit
) {
var optimizeStorage by rememberSaveable { mutableStateOf(true) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(26.dp))
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups_subscribed),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(2.dp)
)
Text(
text = stringResource(R.string.BackupSetupCompleteBottomSheet__title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
Text(
text = stringResource(R.string.BackupSetupCompleteBottomSheet__body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
Rows.ToggleRow(
checked = optimizeStorage,
text = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_storage),
label = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_subtitle),
onCheckChanged = { optimizeStorage = it },
modifier = Modifier.padding(bottom = 24.dp)
)
Buttons.LargeTonal(
onClick = { onBackUpNowClick(optimizeStorage) },
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 56.dp)
) {
Text(text = stringResource(R.string.BackupSetupCompleteBottomSheet__back_up_now))
}
}
}
@DayNightPreviews
@Composable
private fun BackupSetupCompleteBottomSheetPreview() {
Previews.BottomSheetContentPreview {
SetupCompleteSheetContent(
onBackUpNowClick = {}
)
}
}
@@ -0,0 +1,236 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.gibiBytes
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
import org.signal.core.ui.R as CoreUiR
/**
* Bottom sheet that upsells paid backup plans to users.
*/
class BackupUpsellBottomSheet : UpgradeToPaidTierBottomSheet() {
companion object {
private const val ARG_SHOW_POST_PAYMENT = "show_post_payment"
@JvmStatic
fun create(showPostPaymentSheet: Boolean): DialogFragment {
return BackupUpsellBottomSheet().apply {
arguments = bundleOf(ARG_SHOW_POST_PAYMENT to showPostPaymentSheet)
}
}
}
private val showPostPaymentSheet: Boolean by lazy(LazyThreadSafetyMode.NONE) {
requireArguments().getBoolean(ARG_SHOW_POST_PAYMENT, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (showPostPaymentSheet) {
parentFragmentManager.setFragmentResultListener(RESULT_KEY, requireActivity()) { _, bundle ->
if (bundle.getBoolean(RESULT_KEY, false)) {
BackupSetupCompleteBottomSheet().show(parentFragmentManager, "backup_setup_complete")
}
}
}
}
@Composable
override fun UpgradeSheetContent(
paidBackupType: MessageBackupsType.Paid,
freeBackupType: MessageBackupsType.Free,
isSubscribeEnabled: Boolean,
onSubscribeClick: () -> Unit
) {
UpsellSheetContent(
paidBackupType = paidBackupType,
isSubscribeEnabled = isSubscribeEnabled,
onSubscribeClick = onSubscribeClick,
onNoThanksClick = { dismissAllowingStateLoss() }
)
}
}
@Composable
private fun UpsellSheetContent(
paidBackupType: MessageBackupsType.Paid,
isSubscribeEnabled: Boolean,
onSubscribeClick: () -> Unit,
onNoThanksClick: () -> Unit
) {
val resources = LocalContext.current.resources
val pricePerMonth = remember(paidBackupType) {
FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(26.dp))
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(2.dp)
)
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
FeatureCard(pricePerMonth = pricePerMonth)
Buttons.LargeTonal(
enabled = isSubscribeEnabled,
onClick = onSubscribeClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = stringResource(R.string.BackupUpsellBottomSheet__subscribe_for, pricePerMonth))
}
TextButton(
enabled = isSubscribeEnabled,
onClick = onNoThanksClick,
modifier = Modifier.padding(bottom = 32.dp)
) {
Text(text = stringResource(R.string.BackupUpsellBottomSheet__no_thanks))
}
}
}
@Composable
private fun FeatureCard(pricePerMonth: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp)
) {
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__price_per_month, pricePerMonth),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__text_and_all_media),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp)
)
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__full_text_media_backup))
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__storage_100gb))
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__save_on_device_storage))
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__thanks_for_supporting))
}
}
@Composable
private fun FeatureBullet(text: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 2.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@DayNightPreviews
@Composable
private fun BackupUpsellBottomSheetPreview() {
Previews.BottomSheetContentPreview {
UpsellSheetContent(
paidBackupType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal("1.99"), Currency.getInstance("USD")),
mediaTtl = 30.days,
storageAllowanceBytes = 100.gibiBytes.inWholeBytes
),
isSubscribeEnabled = true,
onSubscribeClick = {},
onNoThanksClick = {}
)
}
}
@@ -291,7 +291,11 @@ public class ConversationItemFooter extends ConstraintLayout {
dateView.setText(null);
} else if (messageRecord.isFailed()) {
int errorMsg;
if (messageRecord.hasFailedWithNetworkFailures()) {
if (messageRecord.isFailedAdminDelete() && messageRecord.isIdentityMismatchFailure()) {
errorMsg = R.string.ConversationItem_error_partially_not_deleted;
} else if (messageRecord.isFailedAdminDelete()) {
errorMsg = R.string.ConversationItem_error_delete_failed;
} else if (messageRecord.hasFailedWithNetworkFailures()) {
errorMsg = R.string.ConversationItem_error_network_not_delivered;
} else if (messageRecord.getToRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) {
errorMsg = R.string.ConversationItem_error_partially_not_delivered;
@@ -397,7 +401,7 @@ public class ConversationItemFooter extends ConstraintLayout {
}
if (onlyShowSendingStatus) {
if (messageRecord.isOutgoing() && messageRecord.isPending()) {
if (messageRecord.isPending()) {
deliveryStatusView.setPending();
} else {
deliveryStatusView.setNone();
@@ -135,7 +135,17 @@ public class EmojiTextView extends AppCompatTextView {
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
}
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
textDirection = TextDirectionHeuristics.FIRSTSTRONG_RTL;
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
setTextDirection(TEXT_DIRECTION_FIRST_STRONG_RTL);
}
} else {
textDirection = TextDirectionHeuristics.ANYRTL_LTR;
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
setTextDirection(TEXT_DIRECTION_ANY_RTL);
}
}
setEmojiCompatEnabled(useSystemEmoji());
}
@@ -13,8 +13,10 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -29,7 +31,9 @@ import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Launchers
import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
@@ -38,6 +42,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.milliseconds
private val TAG = Log.tag(LocalBackupsFragment::class)
@@ -126,6 +131,7 @@ class LocalBackupsFragment : ComposeFragment() {
val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val backupKeyUpdatedMessage = stringResource(R.string.OnDeviceBackupsFragment__backup_key_updated)
var upgradeInProgress by remember { mutableStateOf(false) }
MessageBackupsKeyVerifyScreen(
backupKey = state.accountEntropyPool.displayValue,
@@ -138,7 +144,9 @@ class LocalBackupsFragment : ComposeFragment() {
backstack.removeAll { it != LocalBackupsNavKey.SETTINGS }
scope.launch {
upgradeInProgress = true
viewModel.handleUpgrade(requireContext())
upgradeInProgress = false
snackbarHostState.showSnackbar(
message = backupKeyUpdatedMessage
@@ -146,6 +154,12 @@ class LocalBackupsFragment : ComposeFragment() {
}
}
)
Dialogs.IndeterminateProgressDialog(
visible = upgradeInProgress,
delayDuration = 100.milliseconds,
minimumDisplayDuration = 500.milliseconds
)
}
else -> error("Unknown key: $key")
@@ -167,7 +181,7 @@ private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
backStack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY)
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, uri), Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, StorageUtil.getDisplayPath(context, uri)), Toast.LENGTH_SHORT).show()
} else {
Log.w(TAG, "Unified backup location selection cancelled or failed")
}
@@ -98,6 +98,7 @@ class DefaultLocalBackupsSettingsCallback(
override fun onCreateBackupClick() {
if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
Log.i(TAG, "Queueing backup...")
viewModel.onBackupStarted()
enqueueArchive(false)
} else {
Permissions.with(fragment)
@@ -105,6 +106,7 @@ class DefaultLocalBackupsSettingsCallback(
.ifNecessary()
.onAllGranted {
Log.i(TAG, "Queuing backup...")
viewModel.onBackupStarted()
enqueueArchive(false)
}
.withPermanentDenialDialog(
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -16,6 +17,7 @@ import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.BackupPassphrase
@@ -50,7 +52,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
private val internalSettingsState = MutableStateFlow(
LocalBackupsSettingsState(
backupsEnabled = SignalStore.backup.newLocalBackupsEnabled,
folderDisplayName = SignalStore.backup.newLocalBackupsDirectory
folderDisplayName = getDisplayName(AppDependencies.application, SignalStore.backup.newLocalBackupsDirectory)
)
)
@@ -70,7 +72,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
viewModelScope.launch {
SignalStore.backup.newLocalBackupsDirectoryFlow.collect { directory ->
internalSettingsState.update { it.copy(folderDisplayName = directory) }
internalSettingsState.update { it.copy(folderDisplayName = getDisplayName(applicationContext, directory)) }
}
}
@@ -114,6 +116,19 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
}
fun onBackupStarted() {
val context = AppDependencies.application
internalSettingsState.update {
it.copy(
progress = BackupProgressState.InProgress(
summary = context.getString(R.string.BackupsPreferenceFragment__in_progress),
percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, 0),
progressFraction = null
)
)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onBackupEvent(event: LocalBackupV2Event) {
val context = AppDependencies.application
@@ -153,13 +168,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
withContext(Dispatchers.IO) {
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
AppDependencies.jobManager.flush()
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
BackupPassphrase.set(context, null)
SignalStore.settings.isBackupEnabled = false
BackupUtil.deleteAllBackups()
}
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
BackupPassphrase.set(context, null)
SignalStore.settings.isBackupEnabled = false
BackupUtil.deleteAllBackups()
}
SignalStore.backup.newLocalBackupsEnabled = true
@@ -167,6 +182,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
}
private fun getDisplayName(context: Context, directoryUri: String?): String? {
if (directoryUri == null) {
return null
}
return StorageUtil.getDisplayPath(context, Uri.parse(directoryUri))
}
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
@@ -190,6 +190,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
promptUserForSentTimestamp()
}
)
switchPref(
title = DSLSettingsText.from("Disable internal user flag"),
summary = DSLSettingsText.from("Experience life as a non-internal user. Force-stop the app to be an internal user again."),
isChecked = state.disableInternalUser,
onClick = {
viewModel.setDisableInternalUser(!state.disableInternalUser)
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("App UI"))
@@ -31,5 +31,6 @@ data class InternalSettingsState(
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val forceSplitPane: Boolean,
val useNewMediaActivity: Boolean
val useNewMediaActivity: Boolean,
val disableInternalUser: Boolean
)
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.keyvalue.InternalValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.livedata.Store
class InternalSettingsViewModel(private val repository: InternalSettingsRepository) : ViewModel() {
@@ -202,7 +203,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
forceSplitPane = SignalStore.internal.forceSplitPane,
useNewMediaActivity = SignalStore.internal.useNewMediaActivity
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
disableInternalUser = RemoteConfig.internalUserDisabled
)
fun onClearOnboardingState() {
@@ -213,6 +215,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
StoryOnboardingDownloadJob.enqueueIfNeeded()
}
fun setDisableInternalUser(disabled: Boolean) {
RemoteConfig.internalUserDisabled = disabled
refresh()
}
fun setForceSplitPane(forceSplitPane: Boolean) {
SignalStore.internal.forceSplitPane = forceSplitPane
refresh()
@@ -592,11 +592,19 @@ class ConversationSettingsFragment :
if (!state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
title = if (RemoteConfig.internalUser) {
DSLSettingsText.from("${getString(R.string.ConversationSettingsFragment__sounds_and_notifications)} (Internal Only)")
} else {
DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications)
},
icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
val action = if (RemoteConfig.internalUser) {
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(state.recipient.id)
} else {
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
}
navController.safeNavigate(action)
}
@@ -853,17 +861,13 @@ class ConversationSettingsFragment :
)
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 = canSetMemberLabel,
isEnabled = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered,
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()
}
)
}
@@ -522,8 +522,8 @@ sealed class ConversationSettingsViewModel(
}
}
private fun loadCanSetMemberLabel(v2GroupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
val canSetLabel = MemberLabelRepository.instance.canSetLabel(v2GroupId, Recipient.self())
private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
val canSetLabel = MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self())
store.update {
it.copy(
specificSettingsState = it.requireGroupSettingsState().copy(
@@ -63,9 +63,10 @@ object RecipientPreference {
private var recipient: Recipient? = null
private var canSetMemberLabel: Boolean = false
private var memberLabel: StyledMemberLabel? = null
private val recipientObserver = Observer<Recipient> { recipient ->
onRecipientChanged(recipient = recipient, memberLabel = null, canSetMemberLabel = canSetMemberLabel)
onRecipientChanged(recipient = recipient, memberLabel = memberLabel, canSetMemberLabel = canSetMemberLabel)
}
override fun bind(model: Model) {
@@ -82,6 +83,7 @@ object RecipientPreference {
}
canSetMemberLabel = model.canSetMemberLabel
memberLabel = model.memberLabel
if (model.lifecycleOwner != null) {
observeRecipient(model.lifecycleOwner, model.recipient)
@@ -0,0 +1,57 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
/**
* Represents all user-driven actions that can occur on the Sounds & Notifications settings screen.
*/
sealed interface SoundsAndNotificationsEvent {
/**
* Mutes notifications for this recipient until the given epoch-millisecond timestamp.
*
* @param muteUntil Epoch-millisecond timestamp after which notifications should resume.
* Use [Long.MAX_VALUE] to mute indefinitely.
*/
data class SetMuteUntil(val muteUntil: Long) : SoundsAndNotificationsEvent
/**
* Clears any active mute, immediately restoring notifications for this recipient.
*/
data object Unmute : SoundsAndNotificationsEvent
/**
* Updates the mention notification setting for this recipient.
* Only relevant for group conversations that support @mentions.
*
* @param setting The new [NotificationSetting] to apply for @mention notifications.
*/
data class SetMentionSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Updates the call notification setting for this recipient.
* Controls whether incoming calls still produce notifications while the conversation is muted.
*
* @param setting The new [NotificationSetting] to apply for call notifications.
*/
data class SetCallNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Updates the reply notification setting for this recipient.
* Controls whether replies directed at the current user still produce notifications while muted.
*
* @param setting The new [NotificationSetting] to apply for reply notifications.
*/
data class SetReplyNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Signals that the user tapped the "Custom Notifications" row and wishes to navigate to the
* [custom notifications settings screen][org.thoughtcrime.securesms.components.settings.conversation.sounds.custom.CustomNotificationsSettingsFragment].
*/
data object NavigateToCustomNotifications : SoundsAndNotificationsEvent
}
@@ -82,7 +82,7 @@ class SoundsAndNotificationsSettingsFragment :
)
if (state.hasMentionsSupport) {
val mentionSelection = if (state.mentionSetting == RecipientTable.MentionSetting.ALWAYS_NOTIFY) {
val mentionSelection = if (state.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY) {
0
} else {
1
@@ -96,9 +96,9 @@ class SoundsAndNotificationsSettingsFragment :
onSelected = {
viewModel.setMentionSetting(
if (it == 0) {
RecipientTable.MentionSetting.ALWAYS_NOTIFY
RecipientTable.NotificationSetting.ALWAYS_NOTIFY
} else {
RecipientTable.MentionSetting.DO_NOT_NOTIFY
RecipientTable.NotificationSetting.DO_NOT_NOTIFY
}
)
}
@@ -0,0 +1,59 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.Navigation
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class SoundsAndNotificationsSettingsFragment2 : ComposeFragment() {
private val viewModel: SoundsAndNotificationsSettingsViewModel2 by viewModels(
factoryProducer = {
val recipientId = SoundsAndNotificationsSettingsFragment2Args.fromBundle(requireArguments()).recipientId
SoundsAndNotificationsSettingsViewModel2.Factory(recipientId)
}
)
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
if (!state.channelConsistencyCheckComplete || state.recipientId == Recipient.UNKNOWN.id) {
return
}
SoundsAndNotificationsSettingsScreen(
state = state,
formatMuteUntil = { it.formatMutedUntil(requireContext()) },
onEvent = { event ->
when (event) {
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> {
val action = SoundsAndNotificationsSettingsFragment2Directions
.actionSoundsAndNotificationsSettingsFragment2ToCustomNotificationsSettingsFragment(state.recipientId)
Navigation.findNavController(requireView()).safeNavigate(action)
}
else -> viewModel.onEvent(event)
}
},
onNavigationClick = {
requireActivity().onBackPressedDispatcher.onBackPressed()
},
onMuteClick = {
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner) { muteUntil ->
viewModel.onEvent(SoundsAndNotificationsEvent.SetMuteUntil(muteUntil))
}
}
)
}
}
@@ -25,7 +25,7 @@ class SoundsAndNotificationsSettingsRepository(private val context: Context) {
}
}
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.MentionSetting) {
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.NotificationSetting) {
SignalExecutors.BOUNDED.execute {
SignalDatabase.recipients.setMentionSetting(recipientId, mentionSetting)
}
@@ -0,0 +1,309 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.signal.core.ui.R as CoreUiR
@Composable
fun SoundsAndNotificationsSettingsScreen(
state: SoundsAndNotificationsSettingsState2,
formatMuteUntil: (Long) -> String,
onEvent: (SoundsAndNotificationsEvent) -> Unit,
onNavigationClick: () -> Unit,
onMuteClick: () -> Unit
) {
val isMuted = state.muteUntil > 0
var showUnmuteDialog by remember { mutableStateOf(false) }
Scaffolds.Settings(
title = stringResource(R.string.ConversationSettingsFragment__sounds_and_notifications),
onNavigationClick = onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back)
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
// Custom notifications
item {
val summary = if (state.hasCustomNotificationSettings) {
stringResource(R.string.preferences_on)
} else {
stringResource(R.string.preferences_off)
}
Rows.TextRow(
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications),
label = summary,
icon = painterResource(R.drawable.ic_speaker_24),
onClick = { onEvent(SoundsAndNotificationsEvent.NavigateToCustomNotifications) }
)
}
// Mute
item {
val muteSummary = if (isMuted) {
formatMuteUntil(state.muteUntil)
} else {
stringResource(R.string.SoundsAndNotificationsSettingsFragment__not_muted)
}
val muteIcon = if (isMuted) {
R.drawable.ic_bell_disabled_24
} else {
R.drawable.ic_bell_24
}
Rows.TextRow(
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications),
label = muteSummary,
icon = painterResource(muteIcon),
onClick = {
if (isMuted) showUnmuteDialog = true else onMuteClick()
}
)
}
// Divider + When muted section
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__when_muted))
}
// Calls
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls_dialog_message),
icon = painterResource(CoreUiR.drawable.symbol_phone_24),
setting = state.callNotificationSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetCallNotificationSetting(it)) }
)
}
// Mentions (only for groups)
if (state.hasMentionsSupport) {
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions_dialog_message),
icon = painterResource(R.drawable.ic_at_24),
setting = state.mentionSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetMentionSetting(it)) }
)
}
}
// Replies (only for groups)
if (state.hasMentionsSupport) {
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_dialog_message),
icon = painterResource(R.drawable.symbol_reply_24),
setting = state.replyNotificationSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetReplyNotificationSetting(it)) }
)
}
}
}
}
if (showUnmuteDialog) {
Dialogs.SimpleAlertDialog(
title = Dialogs.NoTitle,
body = formatMuteUntil(state.muteUntil),
confirm = stringResource(R.string.ConversationSettingsFragment__unmute),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(SoundsAndNotificationsEvent.Unmute) },
onDismiss = { showUnmuteDialog = false }
)
}
}
@Composable
private fun NotificationSettingRow(
title: String,
dialogTitle: String,
dialogMessage: String,
icon: Painter,
setting: NotificationSetting,
onSelected: (NotificationSetting) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
val labels = arrayOf(
stringResource(R.string.SoundsAndNotificationsSettingsFragment__always_notify),
stringResource(R.string.SoundsAndNotificationsSettingsFragment__do_not_notify)
)
val selectedLabel = if (setting == NotificationSetting.ALWAYS_NOTIFY) labels[0] else labels[1]
Rows.TextRow(
text = title,
label = selectedLabel,
icon = icon,
onClick = { showDialog = true }
)
if (showDialog) {
NotificationSettingDialog(
title = dialogTitle,
message = dialogMessage,
labels = labels,
selectedIndex = if (setting == NotificationSetting.ALWAYS_NOTIFY) 0 else 1,
onDismiss = { showDialog = false },
onSelected = { index ->
onSelected(if (index == 0) NotificationSetting.ALWAYS_NOTIFY else NotificationSetting.DO_NOT_NOTIFY)
showDialog = false
}
)
}
}
@Composable
private fun NotificationSettingDialog(
title: String,
message: String,
labels: Array<String>,
selectedIndex: Int,
onDismiss: () -> Unit,
onSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = AlertDialogDefaults.shape,
color = SignalTheme.colors.colorSurface2
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.padding(top = 24.dp)
.horizontalGutters()
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(top = 8.dp)
.horizontalGutters()
)
Column(modifier = Modifier.padding(top = 16.dp, bottom = 16.dp)) {
labels.forEachIndexed { index, label ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable { onSelected(index) }
.horizontalGutters()
) {
RadioButton(
selected = index == selectedIndex,
onClick = { onSelected(index) }
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}
}
}
}
@DayNightPreviews
@Composable
private fun SoundsAndNotificationsSettingsScreenMutedPreview() {
Previews.Preview {
SoundsAndNotificationsSettingsScreen(
state = SoundsAndNotificationsSettingsState2(
muteUntil = Long.MAX_VALUE,
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
replyNotificationSetting = NotificationSetting.DO_NOT_NOTIFY,
hasMentionsSupport = true,
hasCustomNotificationSettings = false,
channelConsistencyCheckComplete = true
),
formatMuteUntil = { "Always" },
onEvent = {},
onNavigationClick = {},
onMuteClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun SoundsAndNotificationsSettingsScreenUnmutedPreview() {
Previews.Preview {
SoundsAndNotificationsSettingsScreen(
state = SoundsAndNotificationsSettingsState2(
muteUntil = 0L,
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
replyNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
hasMentionsSupport = false,
hasCustomNotificationSettings = true,
channelConsistencyCheckComplete = true
),
formatMuteUntil = { "" },
onEvent = {},
onNavigationClick = {},
onMuteClick = {}
)
}
}
@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
data class SoundsAndNotificationsSettingsState(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val muteUntil: Long = 0L,
val mentionSetting: RecipientTable.MentionSetting = RecipientTable.MentionSetting.DO_NOT_NOTIFY,
val mentionSetting: RecipientTable.NotificationSetting = RecipientTable.NotificationSetting.DO_NOT_NOTIFY,
val hasCustomNotificationSettings: Boolean = false,
val hasMentionsSupport: Boolean = false,
val channelConsistencyCheckComplete: Boolean = false
@@ -0,0 +1,23 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
data class SoundsAndNotificationsSettingsState2(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val muteUntil: Long = 0L,
val mentionSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val callNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val replyNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val hasCustomNotificationSettings: Boolean = false,
val hasMentionsSupport: Boolean = false,
val channelConsistencyCheckComplete: Boolean = false
) {
val isMuted = muteUntil > 0
}
@@ -38,7 +38,7 @@ class SoundsAndNotificationsSettingsViewModel(
repository.setMuteUntil(recipientId, 0L)
}
fun setMentionSetting(mentionSetting: RecipientTable.MentionSetting) {
fun setMentionSetting(mentionSetting: RecipientTable.NotificationSetting) {
repository.setMentionSetting(recipientId, mentionSetting)
}
@@ -0,0 +1,102 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
class SoundsAndNotificationsSettingsViewModel2(
private val recipientId: RecipientId
) : ViewModel(), RecipientForeverObserver {
private val _state = MutableStateFlow(SoundsAndNotificationsSettingsState2())
val state: StateFlow<SoundsAndNotificationsSettingsState2> = _state
private val liveRecipient = Recipient.live(recipientId)
init {
liveRecipient.observeForever(this)
onRecipientChanged(liveRecipient.get())
viewModelScope.launch(Dispatchers.IO) {
if (NotificationChannels.supported()) {
NotificationChannels.getInstance().ensureCustomChannelConsistency()
}
_state.update { it.copy(channelConsistencyCheckComplete = true) }
}
}
override fun onRecipientChanged(recipient: Recipient) {
_state.update {
it.copy(
recipientId = recipientId,
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
mentionSetting = recipient.mentionSetting,
callNotificationSetting = recipient.callNotificationSetting,
replyNotificationSetting = recipient.replyNotificationSetting,
hasMentionsSupport = recipient.isPushV2Group,
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()
)
}
}
override fun onCleared() {
liveRecipient.removeForeverObserver(this)
}
fun onEvent(event: SoundsAndNotificationsEvent) {
when (event) {
is SoundsAndNotificationsEvent.SetMuteUntil -> applySetMuteUntil(event.muteUntil)
is SoundsAndNotificationsEvent.Unmute -> applySetMuteUntil(0L)
is SoundsAndNotificationsEvent.SetMentionSetting -> applySetMentionSetting(event.setting)
is SoundsAndNotificationsEvent.SetCallNotificationSetting -> applySetCallNotificationSetting(event.setting)
is SoundsAndNotificationsEvent.SetReplyNotificationSetting -> applySetReplyNotificationSetting(event.setting)
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> Unit // Navigation handled by UI
}
}
private fun applySetMuteUntil(muteUntil: Long) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setMuted(recipientId, muteUntil)
}
}
private fun applySetMentionSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setMentionSetting(recipientId, setting)
}
}
private fun applySetCallNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setCallNotificationSetting(recipientId, setting)
}
}
private fun applySetReplyNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setReplyNotificationSetting(recipientId, setting)
}
}
class Factory(private val recipientId: RecipientId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel2(recipientId)))
}
}
}
@@ -69,6 +69,7 @@ import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Renders information about a call (1:1, group, or call link) and provides actions available for
@@ -114,6 +115,12 @@ object CallInfoView {
onShareLinkClicked = callbacks::onShareLinkClicked,
onEditNameClicked = onEditNameClicked,
onBlock = callbacks::onBlock,
onMuteAudio = callbacks::onMuteAudio,
onRemoveFromCall = callbacks::onRemoveFromCall,
onContactDetails = callbacks::onContactDetails,
onViewSafetyNumber = callbacks::onViewSafetyNumber,
onGoToChat = callbacks::onGoToChat,
isInternalUser = RemoteConfig.internalUser,
modifier = modifier
)
}
@@ -122,6 +129,11 @@ object CallInfoView {
fun onShareLinkClicked()
fun onEditNameClicked(name: String)
fun onBlock(callParticipant: CallParticipant)
fun onMuteAudio(callParticipant: CallParticipant)
fun onRemoveFromCall(callParticipant: CallParticipant)
fun onContactDetails(callParticipant: CallParticipant)
fun onViewSafetyNumber(callParticipant: CallParticipant)
fun onGoToChat(callParticipant: CallParticipant)
}
}
@@ -135,7 +147,12 @@ private fun CallInfoPreview() {
controlAndInfoState = ControlAndInfoState(),
onShareLinkClicked = { },
onEditNameClicked = { },
onBlock = { }
onBlock = { },
onMuteAudio = { },
onRemoveFromCall = { },
onContactDetails = { },
onViewSafetyNumber = { },
onGoToChat = { }
)
}
}
@@ -147,8 +164,15 @@ private fun CallInfo(
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onBlock: (CallParticipant) -> Unit,
onMuteAudio: (CallParticipant) -> Unit = {},
onRemoveFromCall: (CallParticipant) -> Unit = {},
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {},
isInternalUser: Boolean = false,
modifier: Modifier = Modifier
) {
var selectedParticipant by remember { mutableStateOf<CallParticipant?>(null) }
val listState = rememberLazyListState()
LaunchedEffect(controlAndInfoState.resetScrollState) {
@@ -252,7 +276,16 @@ private fun CallInfo(
CallParticipantRow(
callParticipant = it,
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
onBlockClicked = onBlock
onBlockClicked = onBlock,
onParticipantClicked = if (isInternalUser) {
{ participant ->
if (!participant.recipient.isSelf) {
selectedParticipant = participant
}
}
} else {
null
}
)
}
@@ -312,6 +345,20 @@ private fun CallInfo(
Spacer(modifier = Modifier.size(48.dp))
}
}
selectedParticipant?.let { participant ->
ParticipantActionsSheet(
callParticipant = participant,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onDismiss = { selectedParticipant = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
@Composable
@@ -336,9 +383,10 @@ private fun CallParticipantRowPreview() {
Previews.Preview {
Surface {
CallParticipantRow(
CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
isSelfAdmin = true
) {}
callParticipant = CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
isSelfAdmin = true,
onBlockClicked = {}
)
}
}
}
@@ -357,7 +405,8 @@ private fun HandRaisedRowPreview() {
private fun CallParticipantRow(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
onBlockClicked: (CallParticipant) -> Unit
onBlockClicked: (CallParticipant) -> Unit,
onParticipantClicked: ((CallParticipant) -> Unit)? = null
) {
CallParticipantRow(
initialRecipient = callParticipant.recipient,
@@ -368,7 +417,12 @@ private fun CallParticipantRow(
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin,
onBlockClicked = { onBlockClicked(callParticipant) }
onBlockClicked = { onBlockClicked(callParticipant) },
onRowClicked = if (onParticipantClicked != null && !callParticipant.recipient.isSelf) {
{ onParticipantClicked(callParticipant) }
} else {
null
}
)
}
@@ -396,14 +450,22 @@ private fun CallParticipantRow(
isMicrophoneEnabled: Boolean,
showHandRaised: Boolean,
canLowerHand: Boolean,
isSelfAdmin: Boolean,
onBlockClicked: () -> Unit
isSelfAdmin: Boolean = false,
onBlockClicked: () -> Unit = {},
onRowClicked: (() -> Unit)? = null
) {
Row(
modifier = Modifier
val rowModifier = if (onRowClicked != null) {
Modifier
.fillMaxWidth()
.clickable(onClick = onRowClicked)
.padding(Rows.defaultPadding())
} else {
Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
}
Row(modifier = rowModifier) {
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(initialRecipient.id)))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
@@ -512,8 +574,9 @@ private fun GroupMemberRow(
isMicrophoneEnabled = false,
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin
) {}
isSelfAdmin = isSelfAdmin,
onBlockClicked = {}
)
}
@Composable
@@ -11,9 +11,10 @@ import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
data class ControlAndInfoState(
val callLink: CallLinkTable.CallLink? = null,
val isGroupAdmin: Boolean = false,
val resetScrollState: Long = 0
) {
fun isSelfAdmin(): Boolean {
return callLink?.credentials?.adminPassBytes != null
return callLink?.credentials?.adminPassBytes != null || isGroupAdmin
}
}
@@ -13,9 +13,12 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsRepository
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
@@ -42,6 +45,16 @@ class ControlsAndInfoViewModel(
disposables += CallLinks.watchCallLink(recipient.requireCallLinkRoomId()).subscribeBy {
_state.value = _state.value.copy(callLink = it)
}
} else if (recipient.isGroup && callRecipientId != recipient.id) {
callRecipientId = recipient.id
disposables += Single.fromCallable {
val groupRecord = SignalDatabase.groups.getGroup(recipient.requireGroupId())
groupRecord.isPresent && groupRecord.get().memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR
}
.subscribeOn(Schedulers.io())
.subscribeBy { isAdmin ->
_state.value = _state.value.copy(isGroupAdmin = isAdmin)
}
}
}
@@ -0,0 +1,235 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.controls
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.toLiveData
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ParticipantActionsSheet(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
val recipient by (
(if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
.observeAsState(initial = callParticipant.recipient)
)
ParticipantActionsSheetContent(
recipient = recipient,
callParticipant = callParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = onDismiss,
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
@Composable
private fun ParticipantActionsSheetContent(
recipient: Recipient,
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
ParticipantHeader(recipient = recipient)
val hasAdminActions = isSelfAdmin && (callParticipant.isMicrophoneEnabled || isCallLink)
if (hasAdminActions) {
Dividers.Default()
if (callParticipant.isMicrophoneEnabled) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__mute_audio),
icon = painterResource(id = R.drawable.symbol_mic_slash_24),
onClick = {
onMuteAudio(callParticipant)
onDismiss()
}
)
}
if (isCallLink) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__remove_from_call),
icon = painterResource(id = R.drawable.symbol_minus_circle_24),
onClick = {
onRemoveFromCall(callParticipant)
onDismiss()
}
)
}
}
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__contact_details),
icon = painterResource(id = R.drawable.symbol_person_24),
onClick = {
onContactDetails(callParticipant)
onDismiss()
}
)
Rows.TextRow(
text = stringResource(id = R.string.ConversationSettingsFragment__view_safety_number),
icon = painterResource(id = R.drawable.symbol_safety_number_24),
onClick = {
onViewSafetyNumber(callParticipant)
onDismiss()
}
)
Rows.TextRow(
text = stringResource(id = R.string.CallContextMenu__go_to_chat),
icon = painterResource(id = R.drawable.symbol_open_24),
onClick = {
onGoToChat(callParticipant)
onDismiss()
}
)
Spacer(modifier = Modifier.size(48.dp))
}
@Composable
private fun ParticipantHeader(recipient: Recipient) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
if (LocalInspectionMode.current) {
Spacer(modifier = Modifier.size(64.dp))
} else {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(64.dp)
) {
it.setAvatarUsingProfile(recipient)
}
}
Spacer(modifier = Modifier.size(12.dp))
Text(
text = recipient.getDisplayName(androidx.compose.ui.platform.LocalContext.current),
style = MaterialTheme.typography.titleLarge
)
val e164 = recipient.e164
if (e164.isPresent) {
Spacer(modifier = Modifier.size(2.dp))
Text(
text = e164.get(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@AllNightPreviews
@Composable
private fun ParticipantActionsSheetAdminPreview() {
Previews.BottomSheetPreview {
ParticipantActionsSheetContent(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
callParticipant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
isMicrophoneEnabled = true
),
isSelfAdmin = true,
isCallLink = true,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
@AllNightPreviews
@Composable
private fun ParticipantActionsSheetNonAdminPreview() {
Previews.BottomSheetPreview {
ParticipantActionsSheetContent(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy"),
callParticipant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
),
isSelfAdmin = false,
isCallLink = false,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
@@ -15,10 +15,13 @@ import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
/**
* Callbacks for the CallInfoView, shared between CallActivity and ControlsAndInfoController.
@@ -60,4 +63,34 @@ class CallInfoCallbacks(
}
.show()
}
override fun onMuteAudio(callParticipant: CallParticipant) {
AppDependencies.signalCallManager.sendRemoteMuteRequest(callParticipant)
}
override fun onRemoveFromCall(callParticipant: CallParticipant) {
MaterialAlertDialogBuilder(activity)
.setNegativeButton(android.R.string.cancel, null)
.setMessage(activity.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(activity)))
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
AppDependencies.signalCallManager.removeFromCallLink(callParticipant)
}
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
AppDependencies.signalCallManager.blockFromCallLink(callParticipant)
}
.show()
}
override fun onContactDetails(callParticipant: CallParticipant) {
activity.startActivity(ConversationSettingsActivity.forRecipient(activity, callParticipant.recipient.id))
}
override fun onViewSafetyNumber(callParticipant: CallParticipant) {
val identityRecord = AppDependencies.protocolStore.aci().identities().getIdentityRecord(callParticipant.recipient.id)
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(activity, identityRecord.orElse(null))
}
override fun onGoToChat(callParticipant: CallParticipant) {
CommunicationActions.startConversation(activity, callParticipant.recipient, null)
}
}
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
@@ -15,7 +16,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
@@ -28,12 +31,17 @@ import org.thoughtcrime.securesms.recipients.RecipientId
fun CallParticipantsPager(
callParticipantsPagerState: CallParticipantsPagerState,
pagerState: PagerState,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onTap: (() -> Unit)? = null,
onParticipantLongPress: ((CallParticipant) -> Unit)? = null
) {
if (callParticipantsPagerState.focusedParticipant == null) {
return
}
val currentOnTap = rememberUpdatedState(onTap)
val currentOnLongPress = rememberUpdatedState(onParticipantLongPress)
val firstParticipantAR = rememberParticipantAspectRatio(
callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink
)
@@ -48,13 +56,24 @@ fun CallParticipantsPager(
modifier = mod,
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
itemModifier.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { currentOnLongPress.value?.invoke(participant) }
)
}
} else {
itemModifier
}
RemoteParticipantContent(
participant = participant,
renderInPip = state.isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
showAudioIndicator = state.callParticipants.size > 1,
modifier = itemModifier
modifier = longPressModifier
)
}
}
@@ -14,6 +14,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -25,7 +26,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
@@ -35,6 +39,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
@@ -48,6 +53,8 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -57,6 +64,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TriggerAlignedPopupState
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiStrings
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
@@ -118,7 +126,15 @@ fun CallScreen(
onCallScreenDialogDismissed: () -> Unit = {},
onWifiToCellularPopupDismissed: () -> Unit = {},
onSwipeToSpeakerHintDismissed: () -> Unit = {},
onRemoteMuteToastDismissed: () -> Unit = {}
onRemoteMuteToastDismissed: () -> Unit = {},
isInternalUser: Boolean = false,
isSelfAdmin: Boolean = false,
isCallLink: Boolean = false,
onMuteAudio: (CallParticipant) -> Unit = {},
onRemoveFromCall: (CallParticipant) -> Unit = {},
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {}
) {
if (webRtcCallState == WebRtcViewModel.State.CALL_INCOMING) {
IncomingCallScreen(
@@ -312,22 +328,53 @@ fun CallScreen(
)
}
} else if (webRtcCallState.isPassedPreJoin) {
var longPressedParticipantId by remember { mutableStateOf<CallParticipantId?>(null) }
val longPressedParticipant = longPressedParticipantId?.let { id ->
callParticipantsPagerState.callParticipants.find { it.callParticipantId == id }
}
CallElementsLayout(
callGridSlot = {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
modifier = Modifier
.fillMaxSize()
.clickable(
onClick = {
Box {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
modifier = Modifier
.fillMaxSize()
.clickable(
onClick = {
scope.launch {
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
}
},
enabled = !callControlsState.skipHiddenState
),
onTap = {
if (!callControlsState.skipHiddenState) {
scope.launch {
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
}
},
enabled = !callControlsState.skipHiddenState
)
)
}
},
onParticipantLongPress = if (isInternalUser) {
{ participant -> longPressedParticipantId = participant.callParticipantId }
} else {
null
}
)
ParticipantContextMenu(
participant = longPressedParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = { longPressedParticipantId = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
},
pictureInPictureSlot = {
MoveableLocalVideoRenderer(
@@ -518,6 +565,140 @@ private fun AnimatedCallStateUpdate(
}
}
@Composable
private fun ParticipantContextMenu(
participant: CallParticipant?,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
DropdownMenu(
expanded = participant != null,
onDismissRequest = onDismiss
) {
val resolved = participant ?: return@DropdownMenu
DropdownMenuItem(
text = {
Text(
text = resolved.recipient.getShortDisplayName(androidx.compose.ui.platform.LocalContext.current),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
onClick = {},
enabled = false
)
// Divider (default divider has too much padding)
Box(
Modifier
.fillMaxWidth()
.height(1.5.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant)
)
if (isSelfAdmin && resolved.isMicrophoneEnabled) {
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__mute_audio)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_mic_slash_24), contentDescription = null) },
onClick = {
onMuteAudio(resolved)
onDismiss()
}
)
}
if (isSelfAdmin && isCallLink) {
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__remove_from_call)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_minus_circle_24), contentDescription = null) },
onClick = {
onRemoveFromCall(resolved)
onDismiss()
}
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__contact_details)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_person_24), contentDescription = null) },
onClick = {
onContactDetails(resolved)
onDismiss()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.ConversationSettingsFragment__view_safety_number)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_safety_number_24), contentDescription = null) },
onClick = {
onViewSafetyNumber(resolved)
onDismiss()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.CallContextMenu__go_to_chat)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_open_24), contentDescription = null) },
onClick = {
onGoToChat(resolved)
onDismiss()
}
)
}
}
@AllNightPreviews
@Composable
private fun ParticipantContextMenuAdminPreview() {
Previews.Preview {
Box {
ParticipantContextMenu(
participant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
isMicrophoneEnabled = true
),
isSelfAdmin = true,
isCallLink = true,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
}
@AllNightPreviews
@Composable
private fun ParticipantContextMenuNonAdminPreview() {
Previews.Preview {
Box {
ParticipantContextMenu(
participant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
),
isSelfAdmin = false,
isCallLink = false,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
}
@AllNightPreviews
@Composable
private fun CallScreenPreview() {
@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import kotlin.time.Duration.Companion.seconds
@@ -173,6 +174,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
}
val controlAndInfoState by controlsAndInfoViewModel.state
SignalTheme(isDarkMode = true) {
CallScreen(
callRecipient = recipient,
@@ -217,7 +220,15 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
isInternalUser = RemoteConfig.internalUser,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onMuteAudio = callInfoCallbacks::onMuteAudio,
onRemoveFromCall = callInfoCallbacks::onRemoveFromCall,
onContactDetails = callInfoCallbacks::onContactDetails,
onViewSafetyNumber = callInfoCallbacks::onViewSafetyNumber,
onGoToChat = callInfoCallbacks::onGoToChat
)
}
}
@@ -146,6 +146,7 @@ import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ProjectionList;
import org.thoughtcrime.securesms.util.RemoteConfig;
@@ -1166,13 +1167,13 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean isAdminDelete = !message.getDeletedByRecipient().equals(message.getMessageRecord().getFromRecipient());
CharSequence body;
if (!isAdminDelete && messageRecord.isOutgoing()) {
if (message.getDeletedByRecipient().equals(Recipient.self())) {
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));
String template = context.getString(R.string.ConversationItem_admin_s_deleted_this_message, SpanUtil.SPAN_PLACE_HOLDER);
int start = template.indexOf(SpanUtil.SPAN_PLACE_HOLDER);
int nameColor = colorizer.getIncomingGroupSenderColor(getContext(), message.getDeletedByRecipient());
SpannableString name = new SpannableString(message.getDeletedByRecipient().getDisplayName(context));
@@ -1180,12 +1181,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
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);
SpannableStringBuilder builder = new SpannableStringBuilder(template);
builder.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant)), 0, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.replace(start, start + SpanUtil.SPAN_PLACE_HOLDER.length(), name);
body = builder;
}
return new SpannableStringBuilder()
@@ -2948,7 +2948,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void onClick(@NonNull View widget) {
if (eventListener != null && batchSelected.isEmpty()) {
if (eventListener != null && batchSelected.isEmpty() && conversationRecipient.get().getGroupId().isPresent()) {
VibrateUtil.vibrateTick(context);
eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId());
}
@@ -126,7 +126,6 @@ internal object ConversationOptionsMenu {
hideMenuItem(menu, R.id.menu_video_secure)
}
}
menuInflater.inflate(R.menu.conversation_group_options, menu)
menuInflater.inflate(R.menu.conversation_active_group_options, menu)
}
@@ -164,8 +163,6 @@ internal object ConversationOptionsMenu {
hideMenuItem(menu, R.id.menu_add_shortcut)
}
hideMenuItem(menu, R.id.menu_group_recipients)
if (isActiveV2Group) {
hideMenuItem(menu, R.id.menu_mute_notifications)
hideMenuItem(menu, R.id.menu_conversation_settings)
@@ -206,7 +203,6 @@ internal object ConversationOptionsMenu {
R.id.menu_add_shortcut -> callback.handleAddShortcut()
R.id.menu_search -> callback.handleSearch()
R.id.menu_add_to_contacts -> callback.handleAddToContacts()
R.id.menu_group_recipients -> callback.handleDisplayGroupRecipients()
R.id.menu_group_settings -> callback.handleManageGroup()
R.id.menu_leave -> callback.handleLeavePushGroup()
R.id.menu_invite -> callback.handleInviteLink()
@@ -276,7 +272,6 @@ internal object ConversationOptionsMenu {
fun handleAddShortcut()
fun handleSearch()
fun handleAddToContacts()
fun handleDisplayGroupRecipients()
fun handleManageGroup()
fun handleLeavePushGroup()
fun handleInviteLink()
@@ -183,7 +183,9 @@ public final class SafetyNumberChangeRepository {
}
}
if (messageRecord.isOutgoing()) {
if (messageRecord.isFailedAdminDelete()) {
processAdminDeletedMessageRecord(changedRecipients, messageRecord);
} else if (messageRecord.isOutgoing()) {
processOutgoingMessageRecord(changedRecipients, messageRecord);
}
@@ -223,6 +225,24 @@ public final class SafetyNumberChangeRepository {
}
}
@WorkerThread
private void processAdminDeletedMessageRecord(@NonNull List<ChangedRecipient> changedRecipients, @NonNull MessageRecord messageRecord) {
Log.d(TAG, "processAdminDeletedMessageRecord");
Set<RecipientId> resendIds = new HashSet<>();
for (ChangedRecipient changedRecipient : changedRecipients) {
RecipientId id = changedRecipient.getRecipient().getId();
IdentityKey identityKey = changedRecipient.getIdentityRecord().getIdentityKey();
SignalDatabase.messages().removeMismatchedIdentity(messageRecord.getId(), id, identityKey);
resendIds.add(id);
}
if (Util.hasItems(resendIds) ) {
MessageSender.resendAdminDelete(messageRecord, resendIds.stream().collect(Collectors.toList()));
}
}
static final class SafetyNumberChangeState {
private final List<ChangedRecipient> changedRecipients;
@@ -94,6 +94,25 @@ object ConversationDialogs {
.show()
}
fun displayDeletionFailedDialog(context: Context, messageRecord: MessageRecord, canRetry: Boolean) {
if (canRetry) {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.conversation_activity__message_failed_to_delete_retry)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.conversation_activity__send) { _, _ ->
SignalExecutors.BOUNDED.execute {
MessageSender.resendAdminDelete(messageRecord, messageRecord.networkFailures.map { it.recipientId })
}
}
.show()
} else {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.conversation_activity__message_failed_to_delete)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
@JvmStatic
fun displayDeleteDialog(context: Context, recipient: Recipient, onDelete: () -> Unit) {
MaterialAlertDialogBuilder(context)
@@ -125,7 +125,6 @@ import org.signal.core.util.setActionItemTint
import org.signal.donations.InAppPaymentType
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.GroupMembersDialog
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.R
@@ -3345,9 +3344,18 @@ class ConversationFragment :
override fun onMessageWithErrorClicked(messageRecord: MessageRecord) {
val recipientId = viewModel.recipientSnapshot?.id ?: return
if (messageRecord.isIdentityMismatchFailure) {
if (messageRecord.isFailedAdminDelete) {
val canRetry = MessageConstraintsUtil.isValidAdminDeleteSend(message = messageRecord, currentTime = System.currentTimeMillis(), isAdmin = conversationGroupViewModel.isAdmin(), isResend = true)
if (messageRecord.isIdentityMismatchFailure && canRetry) {
SafetyNumberBottomSheet
.forIncomingMessageRecord(messageRecord, viewModel.recipientSnapshot!!)
.show(childFragmentManager)
} else {
ConversationDialogs.displayDeletionFailedDialog(requireContext(), messageRecord, canRetry)
}
} else if (messageRecord.isIdentityMismatchFailure) {
SafetyNumberBottomSheet
.forMessageRecord(requireContext(), messageRecord)
.forOutgoingMessageRecord(requireContext(), messageRecord)
.show(childFragmentManager)
} else if (messageRecord.hasFailedWithNetworkFailures()) {
ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord)
@@ -3973,11 +3981,6 @@ class ConversationFragment :
)
}
override fun handleDisplayGroupRecipients() {
val recipientSnapshot = viewModel.recipientSnapshot?.takeIf { it.isGroup } ?: return
GroupMembersDialog(requireActivity(), recipientSnapshot).display()
}
override fun handleManageGroup() {
val recipient = viewModel.recipientSnapshot ?: return
val intent = ConversationSettingsActivity.forGroup(requireContext(), recipient.requireGroupId())
@@ -1285,7 +1285,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
view.setSelected(true);
Collection<Long> id = Collections.singleton(conversation.getThreadRecord().getThreadId());
Set<Long> id = Collections.singleton(conversation.getThreadRecord().getThreadId());
List<ActionItem> items = new ArrayList<>();
@@ -1317,7 +1317,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
if (conversation.getThreadRecord().isArchived()) {
items.add(new ActionItem(R.drawable.symbol_archive_up_24, getResources().getString(R.string.ConversationListFragment_unarchive), () -> handleArchive(id)));
items.add(new ActionItem(R.drawable.symbol_archive_up_24, getResources().getString(R.string.ConversationListFragment_unarchive), () -> handleUnarchive(id)));
} else {
if (!isFromSearch) {
if (viewModel.getCurrentFolder().getFolderType() == ChatFolderRecord.FolderType.ALL &&
@@ -706,6 +706,17 @@ class AttachmentTable(
}
}
/**
* This is "approximate" because it doesn't account for things like duplicates. Only useful as a heuristic.
*/
fun getApproximateTotalMediaSize(): Long {
return readableDatabase
.select("SUM($DATA_SIZE)")
.from(TABLE_NAME)
.run()
.readToSingleLong(0L)
}
fun getOptimizedMediaAttachmentSize(): Long {
return readableDatabase
.select("SUM($DATA_SIZE)")
@@ -106,6 +106,7 @@ import org.thoughtcrime.securesms.database.model.StoryResult
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.StoryType.Companion.fromCode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.database.model.databaseprotos.AdminDeleteStatus
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
@@ -144,6 +145,7 @@ import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalTrace
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.isStory
import org.whispersystems.signalservice.internal.push.SyncMessage
@@ -188,6 +190,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val UNIDENTIFIED = "unidentified"
const val REACTIONS_UNREAD = "reactions_unread"
const val REACTIONS_LAST_SEEN = "reactions_last_seen"
const val REMOTE_DELETED = "remote_deleted"
const val SERVER_GUID = "server_guid"
const val RECEIPT_TIMESTAMP = "receipt_timestamp"
const val EXPORT_STATE = "export_state"
@@ -228,6 +231,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val QUOTE_TARGET_MISSING_ID = -1L
const val ADDRESSABLE_MESSAGE_LIMIT = 5
private const val DELETE_BATCH_SIZE = 1000
const val PARENT_STORY_MISSING_ID = -1L
const val PIN_FOREVER = Long.MAX_VALUE
@@ -273,6 +277,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$VIEW_ONCE INTEGER DEFAULT 0,
$REACTIONS_UNREAD INTEGER DEFAULT 0,
$REACTIONS_LAST_SEEN INTEGER DEFAULT -1,
$REMOTE_DELETED INTEGER DEFAULT 0,
$MENTIONS_SELF INTEGER DEFAULT 0,
$NOTIFIED_TIMESTAMP INTEGER DEFAULT 0,
$SERVER_GUID TEXT DEFAULT NULL,
@@ -1915,9 +1920,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
/**
* Given a set of thread ids, return the count of all messages in the table that match that thread id. This will include *all* messages, and is
* explicitly for use as a "fuzzy total"
* "Approximate" because we're not filtering out stuff like message edits. Only useful as a heuristic.
*/
fun getApproximateTotalMessageCount(): Long {
return readableDatabase.count()
.from(TABLE_NAME)
.run()
.readToSingleLong(0L)
}
fun getApproximateExportableMessageCount(threadIds: Set<Long>): Long {
val queries = SqlUtil.buildCollectionQuery(THREAD_ID, threadIds)
return queries.sumOf {
@@ -3780,6 +3791,45 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
/**
* Sets admin delete status to pending
*/
fun markAsPendingAdminDelete(messageId: Long) {
val messageExtras = MessageExtras(adminDeleteStatus = AdminDeleteStatus(AdminDeleteStatus.Status.PENDING))
writableDatabase
.update(TABLE_NAME)
.values(MESSAGE_EXTRAS to messageExtras.encode())
.where("$ID = ?", messageId)
.run()
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
/**
* Sets admin delete status to failed
*/
fun markAsFailedAdminDelete(messageId: Long) {
val messageExtras = MessageExtras(adminDeleteStatus = AdminDeleteStatus(AdminDeleteStatus.Status.FAILED))
writableDatabase
.update(TABLE_NAME)
.values(MESSAGE_EXTRAS to messageExtras.encode())
.where("$ID = ?", messageId)
.run()
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
/**
* Sets admin delete status to complete.
*/
fun markAsSentAdminDelete(messageId: Long) {
val messageExtras = MessageExtras(adminDeleteStatus = AdminDeleteStatus(AdminDeleteStatus.Status.DONE))
writableDatabase
.update(TABLE_NAME)
.values(MESSAGE_EXTRAS to messageExtras.encode())
.where("$ID = ?", messageId)
.run()
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
/**
* When a message gets deleted, clear the pinned record and remove any references
*/
@@ -3966,14 +4016,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return 0
}
writableDatabase.withinTransaction { db ->
SignalDatabase.messageSearch.dropAfterMessageDeleteTrigger()
SignalDatabase.messageLog.dropAfterMessageDeleteTrigger()
for (threadId in threadsWithPossibleDeletes) {
val subSelect = "SELECT ${TABLE_NAME}.$ID FROM $TABLE_NAME WHERE ${TABLE_NAME}.$THREAD_ID = $threadId $extraWhere LIMIT 1000"
do {
// Bulk deleting FK tables for large message delete efficiency
SignalTrace.beginSection("MessageTable#deleteMessagesInThread")
for (threadId in threadsWithPossibleDeletes) {
val subSelect = "SELECT ${TABLE_NAME}.$ID FROM $TABLE_NAME WHERE ${TABLE_NAME}.$THREAD_ID = $threadId $extraWhere LIMIT $DELETE_BATCH_SIZE"
var deletedCount: Int
do {
deletedCount = writableDatabase.withinTransaction { db ->
db.delete(StorySendTable.TABLE_NAME)
.where("${StorySendTable.TABLE_NAME}.${StorySendTable.MESSAGE_ID} IN ($subSelect)")
.run()
@@ -3986,23 +4034,28 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.where("${CallTable.TABLE_NAME}.${CallTable.MESSAGE_ID} IN ($subSelect)")
.run()
// Must delete rows from FTS table before deleting from main table due to FTS requirement when deleting by rowid
db.delete(SearchTable.FTS_TABLE_NAME)
.where("${SearchTable.FTS_TABLE_NAME}.${SearchTable.ID} IN ($subSelect)")
db.delete(AttachmentTable.TABLE_NAME)
.where("${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} IN ($subSelect)")
.run()
// Actually delete messages
val deletedCount = db.delete(TABLE_NAME)
db.delete(GroupReceiptTable.TABLE_NAME)
.where("${GroupReceiptTable.TABLE_NAME}.${GroupReceiptTable.MMS_ID} IN ($subSelect)")
.run()
db.delete(MentionTable.TABLE_NAME)
.where("${MentionTable.TABLE_NAME}.${MentionTable.MESSAGE_ID} IN ($subSelect)")
.run()
// Delete the messages themselves
db.delete(TABLE_NAME)
.where("$ID IN ($subSelect)")
.run()
}
totalDeletedCount += deletedCount
} while (deletedCount > 0)
}
SignalDatabase.messageSearch.restoreAfterMessageDeleteTrigger()
SignalDatabase.messageLog.restoreAfterMessageDeleteTrigger()
totalDeletedCount += deletedCount
} while (deletedCount > 0)
}
SignalTrace.endSection()
return totalDeletedCount
}
@@ -52,14 +52,10 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
import org.thoughtcrime.securesms.badges.Badges.toDatabaseBadge
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.color.MaterialColor
import org.thoughtcrime.securesms.color.MaterialColor.UnknownColorException
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.AvatarColorHash
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColors.Companion.forChatColor
import org.thoughtcrime.securesms.conversation.colors.ChatColors.Id.Companion.forLongValue
import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper.getChatColors
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.GroupTable.LegacyGroupInsertException
import org.thoughtcrime.securesms.database.GroupTable.ShowAsStoryState
@@ -74,7 +70,6 @@ import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
import org.thoughtcrime.securesms.database.model.databaseprotos.DeviceLastResetTime
import org.thoughtcrime.securesms.database.model.databaseprotos.ExpiringProfileKeyCredentialColumnData
import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras
@@ -173,6 +168,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val STORAGE_SERVICE_ID = "storage_service_id"
const val STORAGE_SERVICE_PROTO = "storage_service_proto"
const val MENTION_SETTING = "mention_setting"
const val CALL_NOTIFICATION_SETTING = "call_notification_setting"
const val REPLY_NOTIFICATION_SETTING = "reply_notification_setting"
const val CAPABILITIES = "capabilities"
const val LAST_SESSION_RESET = "last_session_reset"
const val WALLPAPER = "wallpaper"
@@ -246,7 +243,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
$SEALED_SENDER_MODE INTEGER DEFAULT 0,
$STORAGE_SERVICE_ID TEXT UNIQUE DEFAULT NULL,
$STORAGE_SERVICE_PROTO TEXT DEFAULT NULL,
$MENTION_SETTING INTEGER DEFAULT ${MentionSetting.ALWAYS_NOTIFY.id},
$MENTION_SETTING INTEGER DEFAULT ${NotificationSetting.ALWAYS_NOTIFY.id},
$CAPABILITIES INTEGER DEFAULT 0,
$LAST_SESSION_RESET BLOB DEFAULT NULL,
$WALLPAPER BLOB DEFAULT NULL,
@@ -269,7 +266,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
$NICKNAME_JOINED_NAME TEXT DEFAULT NULL,
$NOTE TEXT DEFAULT NULL,
$MESSAGE_EXPIRATION_TIME_VERSION INTEGER DEFAULT 1 NOT NULL,
$KEY_TRANSPARENCY_DATA BLOB DEFAULT NULL
$KEY_TRANSPARENCY_DATA BLOB DEFAULT NULL,
$CALL_NOTIFICATION_SETTING INTEGER DEFAULT ${NotificationSetting.ALWAYS_NOTIFY.id},
$REPLY_NOTIFICATION_SETTING INTEGER DEFAULT ${NotificationSetting.ALWAYS_NOTIFY.id}
)
"""
@@ -318,6 +317,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SEALED_SENDER_MODE,
STORAGE_SERVICE_ID,
MENTION_SETTING,
CALL_NOTIFICATION_SETTING,
REPLY_NOTIFICATION_SETTING,
CAPABILITIES,
WALLPAPER,
WALLPAPER_URI,
@@ -1650,7 +1651,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
fun setMentionSetting(id: RecipientId, mentionSetting: MentionSetting) {
fun setMentionSetting(id: RecipientId, mentionSetting: NotificationSetting) {
val values = ContentValues().apply {
put(MENTION_SETTING, mentionSetting.id)
}
@@ -1661,6 +1662,30 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
fun setCallNotificationSetting(id: RecipientId, setting: NotificationSetting) {
val values = ContentValues().apply {
put(CALL_NOTIFICATION_SETTING, setting.id)
}
if (update(id, values)) {
// TODO rotate storageId once this is actually synced in storage service
// rotateStorageId(id)
AppDependencies.databaseObserver.notifyRecipientChanged(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
fun setReplyNotificationSetting(id: RecipientId, setting: NotificationSetting) {
val values = ContentValues().apply {
put(REPLY_NOTIFICATION_SETTING, setting.id)
}
if (update(id, values)) {
// TODO rotate storageId once this is actually synced in storage service
// rotateStorageId(id)
AppDependencies.databaseObserver.notifyRecipientChanged(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
/**
* Updates the profile key.
*
@@ -3366,62 +3391,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
/**
* We no longer automatically generate a chat color. This method is used only
* in the case of a legacy migration and otherwise should not be called.
*/
@Deprecated("")
fun updateSystemContactColors() {
val db = readableDatabase
val updates: MutableMap<RecipientId, ChatColors> = HashMap()
db.beginTransaction()
try {
db.query(TABLE_NAME, arrayOf(ID, "color", CHAT_COLORS, CUSTOM_CHAT_COLORS_ID, SYSTEM_JOINED_NAME), "$SYSTEM_JOINED_NAME IS NOT NULL AND $SYSTEM_JOINED_NAME != \'\'", null, null, null, null).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val id = cursor.requireLong(ID)
val serializedColor = cursor.requireString("color")
val customChatColorsId = cursor.requireLong(CUSTOM_CHAT_COLORS_ID)
val serializedChatColors = cursor.requireBlob(CHAT_COLORS)
var chatColors: ChatColors? = if (serializedChatColors != null) {
try {
forChatColor(forLongValue(customChatColorsId), ChatColor.ADAPTER.decode(serializedChatColors))
} catch (e: IOException) {
null
}
} else {
null
}
if (chatColors != null) {
return
}
chatColors = if (serializedColor != null) {
try {
getChatColors(MaterialColor.fromSerialized(serializedColor))
} catch (e: UnknownColorException) {
return
}
} else {
return
}
val contentValues = ContentValues().apply {
put(CHAT_COLORS, chatColors.serialize().encode())
put(CUSTOM_CHAT_COLORS_ID, chatColors.id.longValue)
}
db.update(TABLE_NAME, contentValues, "$ID = ?", arrayOf(id.toString()))
updates[RecipientId.from(id)] = chatColors
}
}
} finally {
db.setTransactionSuccessful()
db.endTransaction()
updates.entries.forEach { AppDependencies.databaseObserver.notifyRecipientChanged(it.key) }
}
}
fun queryByInternalFields(query: String): List<RecipientRecord> {
if (query.isBlank()) {
return emptyList()
@@ -4208,7 +4177,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SYSTEM_CONTACT_URI to secondaryRecord.systemContactUri,
PROFILE_SHARING to (primaryRecord.profileSharing || secondaryRecord.profileSharing),
CAPABILITIES to max(primaryRecord.capabilities.rawBits, secondaryRecord.capabilities.rawBits),
MENTION_SETTING to if (primaryRecord.mentionSetting != MentionSetting.ALWAYS_NOTIFY) primaryRecord.mentionSetting.id else secondaryRecord.mentionSetting.id,
MENTION_SETTING to if (primaryRecord.mentionSetting != NotificationSetting.ALWAYS_NOTIFY) primaryRecord.mentionSetting.id else secondaryRecord.mentionSetting.id,
PNI_SIGNATURE_VERIFIED to pniVerified.toInt()
)
@@ -4341,7 +4310,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
put(BLOCKED, if (groupV2.proto.blocked) "1" else "0")
put(MUTE_UNTIL, groupV2.proto.mutedUntilTimestamp)
put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(groupV2.id.raw))
put(MENTION_SETTING, if (groupV2.proto.dontNotifyForMentionsIfMuted) MentionSetting.DO_NOT_NOTIFY.id else MentionSetting.ALWAYS_NOTIFY.id)
put(MENTION_SETTING, if (groupV2.proto.dontNotifyForMentionsIfMuted) NotificationSetting.DO_NOT_NOTIFY.id else NotificationSetting.ALWAYS_NOTIFY.id)
if (groupV2.proto.hasUnknownFields()) {
put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(groupV2.serializedUnknowns!!))
@@ -4922,12 +4891,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
enum class MentionSetting(val id: Int) {
enum class NotificationSetting(val id: Int) {
ALWAYS_NOTIFY(0),
DO_NOT_NOTIFY(1);
companion object {
fun fromId(id: Int): MentionSetting {
fun fromId(id: Int): NotificationSetting {
return entries[id]
}
}
@@ -150,7 +150,9 @@ object RecipientTableCursorUtil {
sealedSenderAccessMode = RecipientTable.SealedSenderAccessMode.fromMode(cursor.requireInt(RecipientTable.SEALED_SENDER_MODE)),
capabilities = readCapabilities(cursor),
storageId = Base64.decodeNullableOrThrow(cursor.requireString(RecipientTable.STORAGE_SERVICE_ID)),
mentionSetting = RecipientTable.MentionSetting.fromId(cursor.requireInt(RecipientTable.MENTION_SETTING)),
mentionSetting = RecipientTable.NotificationSetting.fromId(cursor.requireInt(RecipientTable.MENTION_SETTING)),
callNotificationSetting = RecipientTable.NotificationSetting.fromId(cursor.requireInt(RecipientTable.CALL_NOTIFICATION_SETTING)),
replyNotificationSetting = RecipientTable.NotificationSetting.fromId(cursor.requireInt(RecipientTable.REPLY_NOTIFICATION_SETTING)),
wallpaper = chatWallpaper,
chatColors = chatColors,
avatarColor = avatarColor,
@@ -385,6 +385,22 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
}
}
/**
* Mirrors [runInTransaction] but instead of returning the result of calling block it returns
* whether the transaction completed successfully.
*/
@JvmStatic
fun tryRunInTransaction(block: (SignalSQLiteDatabase) -> Unit): Boolean {
var committed = false
instance!!.signalWritableDatabase.withinTransaction {
block(it)
it.runPostSuccessfulTransaction { committed = true }
}
return committed
}
@get:JvmStatic
@get:JvmName("attachments")
val attachments: AttachmentTable
@@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.JsonUtils.SaneJSONObject
import org.thoughtcrime.securesms.util.SignalTrace
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.isPoll
import org.thoughtcrime.securesms.util.isScheduled
@@ -1315,46 +1316,43 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
fun deleteConversations(selectedConversations: Set<Long>, syncThreadDeletes: Boolean = true) {
SignalTrace.beginSection("ThreadTable#deleteConversations")
Log.d(TAG, "[deleteConversations] Deleting ${selectedConversations.size} chats syncThreadDeletes: $syncThreadDeletes")
val recipientIds = getRecipientIdsForThreadIds(selectedConversations)
val addressableMessages = mutableListOf<ThreadDeleteSyncInfo>()
val queries: List<SqlUtil.Query> = SqlUtil.buildCollectionQuery(ID, selectedConversations)
Log.d(TAG, "[deleteConversations] Enter transaction")
writableDatabase.withinTransaction { db ->
if (syncThreadDeletes) {
for (threadId in selectedConversations) {
val mostRecentMessages = messages.getMostRecentAddressableMessages(threadId, excludeExpiring = false)
val mostRecentNonExpiring = if (mostRecentMessages.size == MessageTable.ADDRESSABLE_MESSAGE_LIMIT && mostRecentMessages.any { it.expiresIn > 0 }) {
messages.getMostRecentAddressableMessages(threadId, excludeExpiring = true)
} else {
emptySet()
}
addressableMessages += ThreadDeleteSyncInfo(threadId, mostRecentMessages, mostRecentNonExpiring)
// Phase 1: Collect sync info (reads only, before any deletion)
if (syncThreadDeletes) {
for (threadId in selectedConversations) {
val mostRecentMessages = messages.getMostRecentAddressableMessages(threadId, excludeExpiring = false)
val mostRecentNonExpiring = if (mostRecentMessages.size == MessageTable.ADDRESSABLE_MESSAGE_LIMIT && mostRecentMessages.any { it.expiresIn > 0 }) {
messages.getMostRecentAddressableMessages(threadId, excludeExpiring = true)
} else {
emptySet()
}
Log.d(TAG, "[deleteConversations] Retrieved sync thread delete addressable messages (${addressableMessages.size})")
} else {
Log.d(TAG, "[deleteConversations] No addressable messages needed")
}
Log.d(TAG, "[deleteConversations] Deactivating threads")
addressableMessages += ThreadDeleteSyncInfo(threadId, mostRecentMessages, mostRecentNonExpiring)
}
Log.d(TAG, "[deleteConversations] Retrieved sync thread delete addressable messages (${addressableMessages.size})")
} else {
Log.d(TAG, "[deleteConversations] No addressable messages needed")
}
// Phase 2: Delete messages (per-batch transactions, write lock released between batches)
Log.d(TAG, "[deleteConversations] Deleting messages in thread")
messages.deleteMessagesInThread(selectedConversations)
// Phase 3: Final lightweight transaction (deactivate threads, clear drafts, update cache)
val queries: List<SqlUtil.Query> = SqlUtil.buildCollectionQuery(ID, selectedConversations)
Log.d(TAG, "[deleteConversations] Deactivating threads and cleaning up")
writableDatabase.withinTransaction { db ->
for (query in queries) {
db.deactivateThread(query)
}
Log.d(TAG, "[deleteConversations] Deleting messages in thread")
messages.deleteMessagesInThread(selectedConversations)
Log.d(TAG, "[deleteConversations] Trimming attachments")
attachments.trimAllAbandonedAttachments()
Log.d(TAG, "[deleteConversations] Deleting abandoned group receipts")
groupReceipts.deleteAbandonedRows()
Log.d(TAG, "[deleteConversations] Deleting abandoned mentions")
mentions.deleteAbandonedMentions()
Log.d(TAG, "[deleteConversations] Clearing drafts")
drafts.clearDrafts(selectedConversations)
Log.d(TAG, "[deleteConversations] Updating threadId cache")
synchronized(threadIdCache) {
for (recipientId in recipientIds) {
threadIdCache.remove(recipientId)
@@ -1378,6 +1376,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
ConversationUtil.clearShortcuts(context, recipientIds)
OptimizeMessageSearchIndexJob.enqueue()
SignalTrace.endSection()
}
@SuppressLint("DiscouragedApi")
@@ -157,6 +157,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V300_AddKeyTranspar
import org.thoughtcrime.securesms.database.helpers.migration.V301_RemoveCallLinkEpoch
import org.thoughtcrime.securesms.database.helpers.migration.V302_AddDeletedByColumn
import org.thoughtcrime.securesms.database.helpers.migration.V303_CaseInsensitiveUsernames
import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNotificationSettings
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -320,10 +321,11 @@ object SignalDatabaseMigrations {
300 to V300_AddKeyTransparencyColumn,
301 to V301_RemoveCallLinkEpoch,
302 to V302_AddDeletedByColumn,
303 to V303_CaseInsensitiveUsernames
303 to V303_CaseInsensitiveUsernames,
304 to V304_CallAndReplyNotificationSettings
)
const val DATABASE_VERSION = 303
const val DATABASE_VERSION = 304
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
@@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.signal.core.util.SqlUtil
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds column to messages to track who has deleted a given message
* Adds column to messages to track who has deleted a given message. Because of an
* OOM crash, we do not drop the remote_deleted column. For users who already completed
* this migration, we add it back in the future.
*/
@Suppress("ClassName")
object V302_AddDeletedByColumn : SignalDatabaseMigration {
@@ -14,18 +17,20 @@ object V302_AddDeletedByColumn : SignalDatabaseMigration {
private val TAG = Log.tag(V302_AddDeletedByColumn::class.java)
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (SqlUtil.columnExists(db, "message", "deleted_by")) {
Log.i(TAG, "Already ran migration!")
return
}
val stopwatch = Stopwatch("migration", decimalPlaces = 2)
db.execSQL("ALTER TABLE message ADD COLUMN deleted_by INTEGER DEFAULT NULL REFERENCES recipient (_id) ON DELETE CASCADE")
stopwatch.split("add-column")
db.execSQL("UPDATE message SET deleted_by = from_recipient_id WHERE remote_deleted > 0")
stopwatch.split("copy-data")
stopwatch.split("update-data")
db.execSQL("ALTER TABLE message DROP COLUMN remote_deleted")
stopwatch.split("drop-column")
db.execSQL("CREATE INDEX message_deleted_by_index ON message (deleted_by)")
db.execSQL("CREATE INDEX IF NOT EXISTS message_deleted_by_index ON message (deleted_by)")
stopwatch.split("create-index")
stopwatch.stop(TAG)
@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds per-conversation notification settings for calls and replies when muted.
*/
@Suppress("ClassName")
object V304_CallAndReplyNotificationSettings : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE recipient ADD COLUMN call_notification_setting INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE recipient ADD COLUMN reply_notification_setting INTEGER DEFAULT 0")
}
}
@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.components.transfercontrols.TransferControlVie
import org.thoughtcrime.securesms.database.MessageTypes;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.databaseprotos.AdminDeleteStatus;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails;
@@ -173,6 +174,15 @@ public abstract class MessageRecord extends DisplayRecord {
return MessageTypes.isLegacyType(type);
}
@Override
public boolean isFailed() {
return super.isFailed() || isFailedAdminDelete();
}
@Override
public boolean isPending() {
return super.isPending() || isPendingAdminDelete();
}
@Override
@WorkerThread
@@ -789,6 +799,18 @@ public abstract class MessageRecord extends DisplayRecord {
return deletedBy;
}
public boolean isPendingAdminDelete() {
return messageExtras != null &&
messageExtras.adminDeleteStatus != null &&
messageExtras.adminDeleteStatus.status == AdminDeleteStatus.Status.PENDING;
}
public boolean isFailedAdminDelete() {
return messageExtras != null &&
messageExtras.adminDeleteStatus != null &&
messageExtras.adminDeleteStatus.status == AdminDeleteStatus.Status.FAILED;
}
public boolean isInMemoryMessageRecord() {
return false;
}
@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState
import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState
import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode
@@ -64,7 +64,9 @@ data class RecipientRecord(
val sealedSenderAccessMode: SealedSenderAccessMode,
val capabilities: Capabilities,
val storageId: ByteArray?,
val mentionSetting: MentionSetting,
val mentionSetting: NotificationSetting,
val callNotificationSetting: NotificationSetting,
val replyNotificationSetting: NotificationSetting,
val wallpaper: ChatWallpaper?,
val chatColors: ChatColors?,
val avatarColor: AvatarColor,
@@ -1,18 +1,8 @@
package org.thoughtcrime.securesms.dependencies
import org.signal.libsignal.internal.mapWithCancellation
import org.signal.libsignal.keytrans.KeyTransparencyException
import org.signal.libsignal.keytrans.VerificationFailedException
import org.signal.libsignal.net.AppExpiredException
import org.signal.libsignal.net.BadRequestError
import org.signal.libsignal.net.ChatServiceException
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.NetworkException
import org.signal.libsignal.net.NetworkProtocolException
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RetryLaterException
import org.signal.libsignal.net.ServerSideErrorException
import org.signal.libsignal.net.TimeoutException
import org.signal.libsignal.net.getOrError
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.ServiceId
@@ -27,69 +17,18 @@ class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.Unauthenti
/**
* Uses KT to verify recipient. This is an unauthenticated and should only be called the first time KT is being requested for this recipient.
*/
suspend fun search(aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyError> {
suspend fun search(aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
.mapWithCancellation(
onSuccess = { RequestResult.Success(Unit) },
onError = { throwable ->
when (throwable) {
is VerificationFailedException,
is KeyTransparencyException,
is AppExpiredException,
is IllegalArgumentException -> {
RequestResult.NonSuccess(KeyTransparencyError(throwable))
}
is ChatServiceException,
is NetworkException,
is NetworkProtocolException -> {
RequestResult.RetryableNetworkError(throwable, null)
}
is RetryLaterException -> {
RequestResult.RetryableNetworkError(throwable, throwable.duration)
}
else -> {
RequestResult.ApplicationError(throwable)
}
}
}
)
}.getOrError()
}
/**
* Monitors KT to verify recipient. This is an unauthenticated and should only be called following a successful [search].
*/
suspend fun monitor(monitorMode: KeyTransparency.MonitorMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyError> {
suspend fun monitor(monitorMode: KeyTransparency.MonitorMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
.mapWithCancellation(
onSuccess = { RequestResult.Success(Unit) },
onError = { throwable ->
when (throwable) {
is TimeoutException,
is ServerSideErrorException,
is NetworkException,
is NetworkProtocolException -> {
RequestResult.RetryableNetworkError(throwable, null)
}
is RetryLaterException -> {
RequestResult.RetryableNetworkError(throwable, throwable.duration)
}
is VerificationFailedException,
is KeyTransparencyException,
is AppExpiredException,
is IllegalArgumentException -> {
RequestResult.NonSuccess(KeyTransparencyError(throwable))
}
else -> {
RequestResult.ApplicationError(throwable)
}
}
}
)
}.getOrError()
}
}
data class KeyTransparencyError(val exception: Throwable) : BadRequestError
@@ -27,6 +27,7 @@ class NewDeviceTransferViewModel : ViewModel() {
SignalStore.registration.localRegistrationMetadata = null
RegistrationUtil.maybeMarkRegistrationComplete()
SignalStore.misc.needsUsernameRestore = true
AppDependencies.jobManager.add(ReclaimUsernameAndLinkJob())
}
@@ -0,0 +1,153 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import android.content.DialogInterface
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.compose.AllDevicePreviews
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
/**
* Informs the user that their member label will be displayed in place of their About text in this group.
*/
class MemberLabelAboutOverrideSheet : ComposeBottomSheetDialogFragment() {
companion object {
const val RESULT_KEY = "member_label_about_override_result"
const val KEY_DONT_SHOW_AGAIN = "dont_show_again"
private const val FRAGMENT_TAG = "MemberLabelAboutOverrideSheet"
fun show(fragmentManager: FragmentManager) {
MemberLabelAboutOverrideSheet().show(fragmentManager, FRAGMENT_TAG)
}
}
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
val callbacks = remember {
object : MemberLabelAboutOverrideUiCallbacks {
override fun onOkClicked() {
setFragmentResult(RESULT_KEY, bundleOf(KEY_DONT_SHOW_AGAIN to false))
dismiss()
}
override fun onDontShowAgainClicked() {
setFragmentResult(RESULT_KEY, bundleOf(KEY_DONT_SHOW_AGAIN to true))
dismiss()
}
}
}
MemberLabelAboutOverrideSheetContent(callbacks = callbacks)
}
override fun onCancel(dialog: DialogInterface) {
setFragmentResult(RESULT_KEY, bundleOf(KEY_DONT_SHOW_AGAIN to false))
super.onCancel(dialog)
}
}
@Composable
private fun MemberLabelAboutOverrideSheetContent(
callbacks: MemberLabelAboutOverrideUiCallbacks = MemberLabelAboutOverrideUiCallbacks.Empty
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(top = 4.dp, bottom = 28.dp, start = 28.dp, end = 28.dp)
.verticalScroll(rememberScrollState())
) {
BottomSheets.Handle()
Image(
painter = painterResource(R.drawable.symbol_tag_filled_64),
contentDescription = null,
modifier = Modifier
.padding(top = 24.dp)
.size(64.dp)
)
Text(
text = stringResource(R.string.MemberLabelsAboutOverride__title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MemberLabelsAboutOverride__body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
Buttons.LargeTonal(
onClick = callbacks::onOkClicked,
modifier = Modifier
.padding(top = 64.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(android.R.string.ok))
}
TextButton(
onClick = callbacks::onDontShowAgainClicked,
modifier = Modifier.padding(top = 16.dp)
) {
Text(text = stringResource(R.string.ConversationFragment_dont_show_again))
}
}
}
private interface MemberLabelAboutOverrideUiCallbacks {
fun onOkClicked()
fun onDontShowAgainClicked()
object Empty : MemberLabelAboutOverrideUiCallbacks {
override fun onOkClicked() = Unit
override fun onDontShowAgainClicked() = Unit
}
}
@AllDevicePreviews
@Composable
private fun MemberLabelAboutOverrideSheetPreview() = Previews.Preview {
MemberLabelAboutOverrideSheetContent()
}
@DayNightPreviews
@Composable
private fun MemberLabelAboutOverrideSheetDarkPreview() = Previews.Preview {
MemberLabelAboutOverrideSheetContent()
}
@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.groups.memberlabel
import android.os.Bundle
import android.view.View
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -21,6 +23,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -40,6 +43,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.AllDevicePreviews
@@ -53,6 +57,7 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.requireParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveState
import org.thoughtcrime.securesms.profiles.ProfileName
@@ -86,6 +91,16 @@ class MemberLabelFragment : ComposeFragment(), ReactWithAnyEmojiBottomSheetDialo
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
childFragmentManager.setFragmentResultListener(MemberLabelAboutOverrideSheet.RESULT_KEY, viewLifecycleOwner) { _, resultData ->
viewModel.onAboutOverrideSheetDismissed(
dontShowAgain = resultData.getBoolean(MemberLabelAboutOverrideSheet.KEY_DONT_SHOW_AGAIN)
)
}
}
@Composable
override fun FragmentContent() {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -107,7 +122,13 @@ class MemberLabelFragment : ComposeFragment(), ReactWithAnyEmojiBottomSheetDialo
}
val networkErrorMessage = stringResource(R.string.GroupMemberLabel__error_cant_save_no_network)
val noPermissionErrorMessage = stringResource(R.string.GroupMemberLabel__error_no_edit_permission)
LaunchedEffect(uiState.showAboutOverrideSheet) {
if (uiState.showAboutOverrideSheet) {
MemberLabelAboutOverrideSheet.show(childFragmentManager)
viewModel.onAboutOverrideSheetShown()
}
}
LaunchedEffect(uiState.saveState) {
when (uiState.saveState) {
@@ -121,10 +142,7 @@ class MemberLabelFragment : ComposeFragment(), ReactWithAnyEmojiBottomSheetDialo
viewModel.onSaveStateConsumed()
}
is SaveState.InsufficientRights -> {
snackbarHostState.showSnackbar(noPermissionErrorMessage)
viewModel.onSaveStateConsumed()
}
is SaveState.InsufficientRights -> throw IllegalStateException("User does not have permission to set member label.")
is SaveState.InProgress, null -> Unit
}
@@ -281,10 +299,14 @@ private fun EmojiPickerButton(
onClick = onEmojiSelected
) {
if (selectedEmoji.isNotNullOrBlank()) {
Text(
text = selectedEmoji,
style = MaterialTheme.typography.bodyLarge
)
ProvideTextStyle(MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp)) {
Emojifier(text = selectedEmoji) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent
)
}
}
} else {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_emoji_plus_24),
@@ -32,7 +32,7 @@ import org.thoughtcrime.securesms.components.emoji.Emojifier
object MemberLabelPill {
@get:Composable
val textStyleCompact: TextStyle
get() = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Normal)
get() = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Normal)
@get:Composable
val textStyleNormal: TextStyle
@@ -50,7 +50,8 @@ fun MemberLabelPill(
text: String,
tintColor: Color,
modifier: Modifier = defaultModifier,
textStyle: TextStyle = MemberLabelPill.textStyleCompact
textStyle: TextStyle = MemberLabelPill.textStyleCompact,
maxLines: Int = 1
) {
val isDark = isSystemInDarkTheme()
val backgroundColor = tintColor.copy(alpha = if (isDark) 0.32f else 0.10f)
@@ -67,7 +68,8 @@ fun MemberLabelPill(
textColor = textColor,
backgroundColor = backgroundColor,
modifier = modifier,
textStyle = textStyle
textStyle = textStyle,
maxLines = maxLines
)
}
@@ -81,9 +83,10 @@ fun MemberLabelPill(
textColor: Color,
backgroundColor: Color,
modifier: Modifier = defaultModifier,
textStyle: TextStyle = MemberLabelPill.textStyleCompact
textStyle: TextStyle = MemberLabelPill.textStyleCompact,
maxLines: Int = 1
) {
val shape = RoundedCornerShape(percent = 50)
val shape = if (maxLines > 1) RoundedCornerShape(24.dp) else RoundedCornerShape(percent = 50)
Row(
modifier = Modifier
@@ -112,7 +115,7 @@ fun MemberLabelPill(
text = annotatedText,
inlineContent = inlineContent,
color = textColor,
maxLines = 1,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
}
@@ -56,7 +56,8 @@ class MemberLabelPillView : AbstractComposeView {
text = label.displayText,
tintColor = tintColor,
modifier = Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding),
textStyle = style.textStyle()
textStyle = style.textStyle(),
maxLines = style.maxLines
)
}
}
@@ -64,7 +65,8 @@ class MemberLabelPillView : AbstractComposeView {
data class Style(
val horizontalPadding: Dp = 12.dp,
val verticalPadding: Dp = 2.dp,
val textStyle: @Composable () -> TextStyle = { MemberLabelPill.textStyleNormal }
val textStyle: @Composable () -> TextStyle = { MemberLabelPill.textStyleNormal },
val maxLines: Int = 1
) {
companion object {
@JvmField
@@ -19,6 +19,8 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.UiHintValues
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -29,7 +31,8 @@ import org.whispersystems.signalservice.api.NetworkResult
*/
class MemberLabelRepository private constructor(
private val context: Context = AppDependencies.application,
private val groupsTable: GroupTable = SignalDatabase.groups
private val groupsTable: GroupTable = SignalDatabase.groups,
private val uiHints: UiHintValues = SignalStore.uiHints
) {
companion object {
@JvmStatic
@@ -94,20 +97,18 @@ class MemberLabelRepository private constructor(
}
/**
* Checks whether the [Recipient] has permission to set their member label in the given group.
* Checks whether [recipient] has permission to set their member label in the given group.
*/
suspend fun canSetLabel(groupId: GroupId.V2, recipient: Recipient): Boolean = withContext(Dispatchers.IO) {
if (!RemoteConfig.sendMemberLabels) return@withContext false
val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext false
groupRecord.attributesAccessControl.allows(groupRecord.memberLevel(recipient))
groupRecord.memberLevel(recipient).isInGroup
}
/**
* Computes the sender [NameColor] for a recipient as seen by other group members.
*/
suspend fun getSenderNameColor(groupId: GroupId.V2, recipientId: RecipientId): NameColor = withContext(Dispatchers.IO) {
val recipient = getRecipient(recipientId)
suspend fun getSenderNameColor(groupId: GroupId.V2, recipient: Recipient): NameColor = withContext(Dispatchers.IO) {
val groupMemberIds = groupsTable
.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
.mapNotNull { it.serviceId.orNull() }
@@ -128,6 +129,14 @@ class MemberLabelRepository private constructor(
GroupManager.updateMemberLabel(context, groupId, sanitizedLabel.text, sanitizedLabel.emoji.orEmpty())
}
}
fun hasDismissedMemberLabelAboutOverrideWarning(): Boolean {
return uiHints.hasDismissedMemberLabelAboutOverrideWarning()
}
fun markMemberLabelAboutOverrideWarningDismissed() {
uiHints.markMemberLabelAboutOverrideWarningDismissed()
}
}
private fun MemberLabel.sanitized(): MemberLabel = this.copy(
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.StringUtil
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException
@@ -25,7 +26,8 @@ import org.whispersystems.signalservice.api.NetworkResult
class MemberLabelViewModel(
private val memberLabelRepo: MemberLabelRepository = MemberLabelRepository.instance,
private val groupId: GroupId.V2,
private val recipientId: RecipientId
private val recipientId: RecipientId,
private val sanitizeEmoji: (String) -> String? = MemberLabel::sanitizeEmoji
) : ViewModel() {
private var originalLabelEmoji: String = ""
@@ -40,16 +42,17 @@ class MemberLabelViewModel(
private fun loadInitialState() {
viewModelScope.launch(SignalDispatchers.IO) {
val memberLabel = memberLabelRepo.getLabel(groupId, recipientId)
val recipient = memberLabelRepo.getRecipient(recipientId)
val memberLabel = memberLabelRepo.getLabel(groupId, recipient)
originalLabelEmoji = memberLabel?.emoji.orEmpty()
originalLabelText = memberLabel?.text.orEmpty()
internalUiState.update {
it.copy(
recipient = memberLabelRepo.getRecipient(recipientId),
recipient = recipient,
labelEmoji = originalLabelEmoji,
labelText = originalLabelText,
senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipientId)
senderNameColor = memberLabelRepo.getSenderNameColor(groupId, recipient)
)
}
}
@@ -85,7 +88,8 @@ class MemberLabelViewModel(
}
private fun hasChanges(labelEmoji: String, labelText: String): Boolean {
return labelEmoji != originalLabelEmoji || MemberLabel.sanitizeLabelText(labelText) != originalLabelText
return sanitizeEmoji(labelEmoji).orEmpty() != originalLabelEmoji ||
MemberLabel.sanitizeLabelText(labelText) != originalLabelText
}
fun save() {
@@ -107,14 +111,26 @@ class MemberLabelViewModel(
)
)
val newSaveState: SaveState = when (result) {
is NetworkResult.Success -> SaveState.Success
when (result) {
is NetworkResult.Success -> {
val isLabelCleared = currentState.sanitizedLabelText.isEmpty() && currentState.labelEmoji.isEmpty()
val selfHasAbout = currentState.recipient?.combinedAboutAndEmoji.isNotNullOrBlank()
val showOverrideSheet = !isLabelCleared && selfHasAbout && !memberLabelRepo.hasDismissedMemberLabelAboutOverrideWarning()
is NetworkResult.NetworkError<*> -> SaveState.NetworkError
internalUiState.update {
if (showOverrideSheet) {
it.copy(showAboutOverrideSheet = true)
} else {
it.copy(saveState = SaveState.Success)
}
}
}
is NetworkResult.NetworkError<*> -> internalUiState.update { it.copy(saveState = SaveState.NetworkError) }
is NetworkResult.ApplicationError<*> -> {
if (result.throwable is GroupInsufficientRightsException) {
SaveState.InsufficientRights
internalUiState.update { it.copy(saveState = SaveState.InsufficientRights) }
} else {
throw result.throwable
}
@@ -122,10 +138,6 @@ class MemberLabelViewModel(
is NetworkResult.StatusCodeError<*> -> throw result.exception
}
internalUiState.update {
it.copy(saveState = newSaveState)
}
}
}
@@ -134,6 +146,21 @@ class MemberLabelViewModel(
it.copy(saveState = null)
}
}
fun onAboutOverrideSheetShown() {
internalUiState.update {
it.copy(showAboutOverrideSheet = false)
}
}
fun onAboutOverrideSheetDismissed(dontShowAgain: Boolean) {
if (dontShowAgain) {
memberLabelRepo.markMemberLabelAboutOverrideWarningDismissed()
}
internalUiState.update {
it.copy(saveState = SaveState.Success)
}
}
}
data class MemberLabelUiState(
@@ -142,7 +169,8 @@ data class MemberLabelUiState(
val recipient: Recipient? = null,
val senderNameColor: NameColor? = null,
val hasChanges: Boolean = false,
val saveState: SaveState? = null
val saveState: SaveState? = null,
val showAboutOverrideSheet: Boolean = false
) {
val sanitizedLabelText: String get() = MemberLabel.sanitizeLabelText(labelText)
@@ -14,11 +14,11 @@ import androidx.core.util.Consumer;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting;
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting;
public final class GroupMentionSettingDialog {
public static void show(@NonNull Context context, @NonNull MentionSetting mentionSetting, @Nullable Consumer<MentionSetting> callback) {
public static void show(@NonNull Context context, @NonNull NotificationSetting mentionSetting, @Nullable Consumer<NotificationSetting> callback) {
SelectionCallback selectionCallback = new SelectionCallback(mentionSetting, callback);
new MaterialAlertDialogBuilder(context)
@@ -30,7 +30,7 @@ public final class GroupMentionSettingDialog {
}
@SuppressLint("InflateParams")
private static View getView(@NonNull Context context, @NonNull MentionSetting mentionSetting, @NonNull SelectionCallback selectionCallback) {
private static View getView(@NonNull Context context, @NonNull NotificationSetting mentionSetting, @NonNull SelectionCallback selectionCallback) {
View root = LayoutInflater.from(context).inflate(R.layout.group_mention_setting_dialog, null, false);
CheckedTextView alwaysNotify = root.findViewById(R.id.group_mention_setting_always_notify);
CheckedTextView dontNotify = root.findViewById(R.id.group_mention_setting_dont_notify);
@@ -40,9 +40,9 @@ public final class GroupMentionSettingDialog {
dontNotify.setChecked(dontNotify == v);
if (alwaysNotify.isChecked()) {
selectionCallback.selection = MentionSetting.ALWAYS_NOTIFY;
selectionCallback.selection = NotificationSetting.ALWAYS_NOTIFY;
} else if (dontNotify.isChecked()) {
selectionCallback.selection = MentionSetting.DO_NOT_NOTIFY;
selectionCallback.selection = NotificationSetting.DO_NOT_NOTIFY;
}
};
@@ -63,11 +63,11 @@ public final class GroupMentionSettingDialog {
private static class SelectionCallback implements DialogInterface.OnClickListener {
@NonNull private final MentionSetting previousMentionSetting;
@NonNull private MentionSetting selection;
@Nullable private final Consumer<MentionSetting> callback;
@NonNull private final NotificationSetting previousMentionSetting;
@NonNull private NotificationSetting selection;
@Nullable private final Consumer<NotificationSetting> callback;
public SelectionCallback(@NonNull MentionSetting previousMentionSetting, @Nullable Consumer<MentionSetting> callback) {
public SelectionCallback(@NonNull NotificationSetting previousMentionSetting, @Nullable Consumer<NotificationSetting> callback) {
this.previousMentionSetting = previousMentionSetting;
this.selection = previousMentionSetting;
this.callback = callback;
@@ -4,6 +4,7 @@ import org.signal.core.models.ServiceId
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.Log.tag
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.documents.NetworkFailure
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -37,15 +38,19 @@ class AdminDeleteSendJob private constructor(
private val TAG = tag(AdminDeleteSendJob::class.java)
@JvmStatic
fun create(messageId: Long): AdminDeleteSendJob? {
val message = SignalDatabase.messages.getMessageRecord(messageId)
fun create(messageId: Long, filterRecipients: List<RecipientId>): AdminDeleteSendJob? {
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
if (message == null) {
return null
}
val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
if (conversationRecipient == null) {
return null
}
val recipientIds = conversationRecipient.participantIds.map { it.toLong() }.toMutableList()
val recipientIds = filterRecipients.ifEmpty { conversationRecipient.participantIds }.map { it.toLong() }.toMutableList()
return AdminDeleteSendJob(
messageId = messageId,
@@ -81,7 +86,11 @@ class AdminDeleteSendJob private constructor(
return Result.failure()
}
val recipients = recipientIds.map { Recipient.resolved(RecipientId.from(it)) }.toMutableList()
val existingNetworkFailures = message.networkFailures.toMutableSet()
val existingIdentityMismatches = message.identityKeyMismatches.toMutableSet()
val targets = (recipientIds + existingIdentityMismatches.map { it.recipientId.toLong() } + existingNetworkFailures.map { it.recipientId.toLong() }).toSet()
val recipients = targets.map { Recipient.resolved(RecipientId.from(it)) }.toMutableList()
val targetSentTimestamp = message.dateSent
val targetAuthor = message.fromRecipient.requireServiceId()
@@ -103,9 +112,22 @@ class AdminDeleteSendJob private constructor(
}
val eligible = RecipientUtil.getEligibleForSending(recipients.filter { it.hasServiceId })
val skippedRecipients = recipients - eligible
val ineligibleRecipients = recipients - eligible
val sendResult = deliver(conversationRecipient, eligible, targetAuthor, targetSentTimestamp)
val completedIds = sendResult.completed.map { it.id }.toSet()
existingNetworkFailures.removeAll { completedIds.contains(it.recipientId) }
existingIdentityMismatches.removeAll { completedIds.contains(it.recipientId) }
val ineligibleIds = (ineligibleRecipients.map { it.id } + sendResult.unregistered).toSet()
existingNetworkFailures.removeAll { ineligibleIds.contains(it.recipientId) }
existingIdentityMismatches.removeAll { ineligibleIds.contains(it.recipientId) }
existingIdentityMismatches.addAll(sendResult.identityMismatch)
SignalDatabase.messages.setNetworkFailures(messageId, existingNetworkFailures)
SignalDatabase.messages.setMismatchedIdentities(messageId, existingIdentityMismatches)
for (completion in sendResult.completed) {
recipientIds.remove(completion.id.toLong())
}
@@ -114,22 +136,35 @@ class AdminDeleteSendJob private constructor(
SignalDatabase.recipients.markUnregistered(unregistered)
}
for (recipient in skippedRecipients) {
for (recipient in ineligibleRecipients) {
recipientIds.remove(recipient.id.toLong())
}
Log.i(TAG, "Completed now: ${sendResult.completed.size} Skipped: ${skippedRecipients.size + sendResult.skipped.size} Remaining: ${recipientIds.size}")
Log.i(TAG, "Completed now: ${sendResult.completed.size} Skipped: ${ineligibleRecipients.size + sendResult.skipped.size} Remaining: ${recipientIds.size}")
if (recipientIds.isEmpty()) {
if (existingNetworkFailures.isEmpty() && existingIdentityMismatches.isEmpty() && recipientIds.isEmpty()) {
SignalDatabase.messages.markAsSentAdminDelete(messageId)
return Result.success()
} else if (existingIdentityMismatches.isNotEmpty()) {
Log.w(TAG, "Failing because there were ${existingIdentityMismatches.size} identity mismatches.")
return Result.failure()
} else {
Log.w(TAG, "Still need to send to ${recipients.size} recipients. Retrying.")
Log.w(TAG, "Still need to send to ${recipientIds.size} recipients. Retrying.")
return Result.retry(defaultBackoff())
}
}
override fun onFailure() {
Log.w(TAG, "Failed to send admin delete to all recipients! ${initialRecipientCount - recipientIds.size} / $initialRecipientCount")
Log.w(TAG, "Failed to send admin delete to all recipients! ${initialRecipientCount - recipientIds.size} / $initialRecipientCount. Marking remaining non-identity mismatched failures as network failure.")
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
if (message == null) {
Log.w(TAG, "Message no longer exists, ignoring.")
} else {
val existingIdentityMismatches = message.identityKeyMismatches.map { it.recipientId.toLong() }
recipientIds.removeAll { existingIdentityMismatches.contains(it) }
SignalDatabase.messages.setNetworkFailures(messageId, recipientIds.map { NetworkFailure(RecipientId.from(it)) }.toSet())
SignalDatabase.messages.markAsFailedAdminDelete(messageId)
}
}
private fun deliver(
@@ -1,9 +1,6 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.signal.libsignal.keytrans.KeyTransparencyException
import org.signal.libsignal.keytrans.VerificationFailedException
import org.signal.libsignal.net.AppExpiredException
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.usernames.Username
@@ -142,28 +139,15 @@ class CheckKeyTransparencyJob private constructor(
}
is RequestResult.NonSuccess -> {
if (result.error.exception is IllegalArgumentException) {
Log.w(TAG, "KT store was corrupted. Restarting and then retrying.")
SignalStore.account.distinguishedHead = null
SignalDatabase.recipients.clearSelfKeyTransparencyData()
Result.retry(defaultBackoff())
} else if (result.error.exception is VerificationFailedException || result.error.exception is KeyTransparencyException) {
if (!showFailure) {
Log.w(TAG, "Verification failure. Enqueuing this job again to run again a day.")
StorageSyncJob.forRemoteChange()
enqueueFollowingFailure()
} else {
Log.w(TAG, "Second verification failure. Showing failure sheet.")
markFailure()
}
Result.failure()
} else if (result.error.exception is AppExpiredException) {
Result.failure()
if (!showFailure) {
Log.w(TAG, "Verification failure. Enqueuing this job again to run again a day.")
StorageSyncJob.forRemoteChange()
enqueueFollowingFailure()
} else {
Log.w(TAG, "Unknown nonsuccess failure. Showing failure sheet.")
Log.w(TAG, "Second verification failure. Showing failure sheet.")
markFailure()
Result.failure()
}
Result.failure()
}
is RequestResult.RetryableNetworkError -> {
if (result.retryAfter != null) {
@@ -173,9 +157,16 @@ class CheckKeyTransparencyJob private constructor(
}
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Unknown application failure. Showing failure sheet.")
markFailure()
Result.failure()
if (result.cause is IllegalArgumentException) {
Log.w(TAG, "KT store was corrupted. Restarting and then retrying.")
SignalStore.account.distinguishedHead = null
SignalDatabase.recipients.clearSelfKeyTransparencyData()
Result.retry(defaultBackoff())
} else {
Log.w(TAG, "Unknown application failure. Showing failure sheet.")
markFailure()
Result.failure()
}
}
}
}
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.RecipientAccessList;
@@ -20,16 +21,18 @@ public final class GroupSendJobHelper {
}
public static @NonNull SendResult getCompletedSends(@NonNull List<Recipient> possibleRecipients, @NonNull Collection<SendMessageResult> results) {
RecipientAccessList accessList = new RecipientAccessList(possibleRecipients);
List<Recipient> completions = new ArrayList<>(results.size());
List<RecipientId> skipped = new ArrayList<>();
List<RecipientId> unregistered = new ArrayList<>();
RecipientAccessList accessList = new RecipientAccessList(possibleRecipients);
List<Recipient> completions = new ArrayList<>(results.size());
List<RecipientId> skipped = new ArrayList<>();
List<RecipientId> unregistered = new ArrayList<>();
List<IdentityKeyMismatch> identityMismatch = new ArrayList<>();
for (SendMessageResult sendMessageResult : results) {
Recipient recipient = accessList.requireByAddress(sendMessageResult.getAddress());
if (sendMessageResult.getIdentityFailure() != null) {
Log.w(TAG, "Identity failure for " + recipient.getId());
identityMismatch.add(new IdentityKeyMismatch(recipient.getId(), sendMessageResult.getIdentityFailure().getIdentityKey()));
}
if (sendMessageResult.isUnregisteredFailure()) {
@@ -63,7 +66,7 @@ public final class GroupSendJobHelper {
}
}
return new SendResult(completions, skipped, unregistered);
return new SendResult(completions, skipped, unregistered, identityMismatch);
}
public static class SendResult {
@@ -76,10 +79,14 @@ public final class GroupSendJobHelper {
/** Recipients that were discovered to be unregistered. Important: items in this list can overlap with other lists in the result. */
public final List<RecipientId> unregistered;
public SendResult(@NonNull List<Recipient> completed, @NonNull List<RecipientId> skipped, @NonNull List<RecipientId> unregistered) {
this.completed = completed;
this.skipped = skipped;
this.unregistered = unregistered;
/** Recipients that were not sent to due to an identity failure. Important: items in this list overlap with other lists in the result. */
public final List<IdentityKeyMismatch> identityMismatch;
public SendResult(@NonNull List<Recipient> completed, @NonNull List<RecipientId> skipped, @NonNull List<RecipientId> unregistered, List<IdentityKeyMismatch> identityMismatch) {
this.completed = completed;
this.skipped = skipped;
this.unregistered = unregistered;
this.identityMismatch = identityMismatch;
}
}
}
@@ -48,6 +48,11 @@ class OptimizeMediaJob private constructor(parameters: Parameters) : Job(paramet
return Result.success()
}
if (SignalStore.backup.backupDownloadNotifierState != null) {
Log.i(TAG, "Backup subscription is pending cancellation, skipping media optimization.")
return Result.success()
}
if (ArchiveRestoreProgress.state.activelyRestoring()) {
ArchiveRestoreProgress.onCancelMediaRestore()
@@ -145,6 +145,17 @@ class UploadAttachmentToArchiveJob private constructor(
if (attachment.uri == null) {
Log.w(TAG, "[$attachmentId]$mediaIdLog Attachment has no uri! Cannot upload.")
ArchiveDatabaseExecutor.runBlocking {
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
return Result.failure()
}
if (attachment.size == 0L) {
Log.w(TAG, "[$attachmentId]$mediaIdLog Attachment has no data (size is 0)! Cannot upload.")
ArchiveDatabaseExecutor.runBlocking {
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
return Result.failure()
}
@@ -10,28 +10,29 @@ public class UiHintValues extends SignalStoreValues {
private static final int NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD = 3;
private static final int HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD = 3;
private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast";
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
private static final String HAS_SET_OR_SKIPPED_USERNAME_CREATION = "uihints.has_set_or_skipped_username_creation";
private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip";
private static final String HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE = "uihints.has_seen_scheduled_messages_info_once";
private static final String HAS_SEEN_SAFETY_NUMBER_NUX = "uihints.has_seen_safety_number_nux";
private static final String DECLINED_NOTIFICATION_LOGS_PROMPT = "uihints.declined_notification_logs";
private static final String LAST_NOTIFICATION_LOGS_PROMPT_TIME = "uihints.last_notification_logs_prompt";
private static final String DISMISSED_BATTERY_SAVER_PROMPT = "uihints.declined_battery_saver_prompt";
private static final String LAST_BATTERY_SAVER_PROMPT = "uihints.last_battery_saver_prompt";
private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt";
private static final String HAS_COMPLETED_USERNAME_ONBOARDING = "uihints.has_completed_username_onboarding";
private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet";
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
private static final String LAST_SUPPORT_VERSION_SEEN = "uihints.last_support_version_seen";
private static final String HAS_EVER_ENABLED_REMOTE_BACKUPS = "uihints.has_ever_enabled_remote_backups";
private static final String HAS_SEEN_CHAT_FOLDERS_EDUCATION_SHEET = "uihints.has_seen_chat_folders_education_sheet";
private static final String HAS_SEEN_LINK_DEVICE_QR_EDUCATION_SHEET = "uihints.has_seen_link_device_qr_education_sheet";
private static final String HAS_DISMISSED_SAVE_STORAGE_WARNING = "uihints.has_dismissed_save_storage_warning";
private static final String HAS_SEEN_PINNED_MESSAGE_SHEET = "uihints.has_seen_pinned_message_sheet";
private static final String HAS_SEEN_VERIFY_AUTO_SHEET = "uihints.has_seen_verify_auto_sheet";
private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast";
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
private static final String HAS_SET_OR_SKIPPED_USERNAME_CREATION = "uihints.has_set_or_skipped_username_creation";
private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip";
private static final String HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE = "uihints.has_seen_scheduled_messages_info_once";
private static final String HAS_SEEN_SAFETY_NUMBER_NUX = "uihints.has_seen_safety_number_nux";
private static final String DECLINED_NOTIFICATION_LOGS_PROMPT = "uihints.declined_notification_logs";
private static final String LAST_NOTIFICATION_LOGS_PROMPT_TIME = "uihints.last_notification_logs_prompt";
private static final String DISMISSED_BATTERY_SAVER_PROMPT = "uihints.declined_battery_saver_prompt";
private static final String LAST_BATTERY_SAVER_PROMPT = "uihints.last_battery_saver_prompt";
private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt";
private static final String HAS_COMPLETED_USERNAME_ONBOARDING = "uihints.has_completed_username_onboarding";
private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet";
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
private static final String LAST_SUPPORT_VERSION_SEEN = "uihints.last_support_version_seen";
private static final String HAS_EVER_ENABLED_REMOTE_BACKUPS = "uihints.has_ever_enabled_remote_backups";
private static final String HAS_SEEN_CHAT_FOLDERS_EDUCATION_SHEET = "uihints.has_seen_chat_folders_education_sheet";
private static final String HAS_SEEN_LINK_DEVICE_QR_EDUCATION_SHEET = "uihints.has_seen_link_device_qr_education_sheet";
private static final String HAS_DISMISSED_SAVE_STORAGE_WARNING = "uihints.has_dismissed_save_storage_warning";
private static final String HAS_SEEN_PINNED_MESSAGE_SHEET = "uihints.has_seen_pinned_message_sheet";
private static final String HAS_SEEN_VERIFY_AUTO_SHEET = "uihints.has_seen_verify_auto_sheet";
private static final String HAS_DISMISSED_MEMBER_LABEL_ABOUT_OVERRIDE_WARNING = "uihints.has_dismissed_member_label_about_override_warning";
UiHintValues(@NonNull KeyValueStore store) {
super(store);
@@ -241,4 +242,12 @@ public class UiHintValues extends SignalStoreValues {
public void setSeenVerifyAutomaticallySheet() {
putBoolean(HAS_SEEN_VERIFY_AUTO_SHEET, true);
}
public boolean hasDismissedMemberLabelAboutOverrideWarning() {
return getBoolean(HAS_DISMISSED_MEMBER_LABEL_ABOUT_OVERRIDE_WARNING, false);
}
public void markMemberLabelAboutOverrideWarningDismissed() {
putBoolean(HAS_DISMISSED_MEMBER_LABEL_ABOUT_OVERRIDE_WARNING, true);
}
}
@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.megaphone
import org.thoughtcrime.securesms.database.model.MegaphoneRecord
import kotlin.time.Duration.Companion.days
/**
* Schedule for backup upsell megaphones that combines:
* 1. Per-megaphone recurring display timing (like [RecurringSchedule])
* 2. Cross-megaphone shared snooze: if any other backup upsell was seen recently, suppress display
*
* @param records All megaphone records, used to find the most recent lastSeen across backup upsell events.
* @param gaps Per-megaphone recurring gaps, same semantics as [RecurringSchedule].
*/
class BackupUpsellSchedule(
private val records: Map<Megaphones.Event, MegaphoneRecord>,
vararg val gaps: Long
) : MegaphoneSchedule {
companion object {
@JvmField
val BACKUP_UPSELL_EVENTS: Set<Megaphones.Event> = setOf(
Megaphones.Event.BACKUPS_GENERIC_UPSELL,
Megaphones.Event.BACKUP_MESSAGE_COUNT_UPSELL,
Megaphones.Event.BACKUP_MEDIA_SIZE_UPSELL,
Megaphones.Event.BACKUP_LOW_STORAGE_UPSELL
)
@JvmField
val MIN_TIME_BETWEEN_BACKUP_UPSELLS: Long = 60.days.inWholeMilliseconds
}
override fun shouldDisplay(seenCount: Int, lastSeen: Long, firstVisible: Long, currentTime: Long): Boolean {
val lastSeen = lastSeen.coerceAtMost(currentTime)
val lastSeenAnyBackupUpsell: Long = records.entries
.filter { it.key in BACKUP_UPSELL_EVENTS }
.mapNotNull { it.value.lastSeen.takeIf { t -> t > 0 } }
.maxOrNull() ?: 0L
if (currentTime - lastSeenAnyBackupUpsell <= MIN_TIME_BETWEEN_BACKUP_UPSELLS) {
return false
}
if (seenCount == 0) {
return true
}
val gap = gaps[minOf(seenCount - 1, gaps.size - 1)]
return lastSeen + gap <= currentTime
}
}
@@ -14,11 +14,14 @@ import androidx.core.app.NotificationManagerCompat;
import com.annimon.stream.Stream;
import com.bumptech.glide.Glide;
import org.signal.core.util.DiskUtil;
import org.signal.core.util.MapUtil;
import org.signal.core.util.SetUtil;
import org.signal.core.util.TranslationDetection;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier;
import org.thoughtcrime.securesms.backup.v2.ui.BackupUpsellBottomSheet;
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
@@ -40,6 +43,7 @@ import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity;
import org.thoughtcrime.securesms.profiles.username.NewWaysToConnectDialogFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.RemoteConfig;
@@ -129,7 +133,10 @@ public final class Megaphones {
// Feature-introduction megaphones should *probably* be added below this divider
put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER);
put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER);
put(Event.TURN_ON_SIGNAL_BACKUPS, shouldShowTurnOnBackupsMegaphone(context) ? new RecurringSchedule(TimeUnit.DAYS.toMillis(30), TimeUnit.DAYS.toMillis(90)) : NEVER);
put(Event.BACKUP_LOW_STORAGE_UPSELL, shouldShowBackupLowStorageUpsell(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60), TimeUnit.DAYS.toMillis(120)) : NEVER);
put(Event.BACKUP_MEDIA_SIZE_UPSELL, shouldShowBackupMediaSizeUpsell() ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60), TimeUnit.DAYS.toMillis(120)) : NEVER);
put(Event.BACKUP_MESSAGE_COUNT_UPSELL, shouldShowBackupMessageCountUpsell(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER);
put(Event.BACKUPS_GENERIC_UPSELL, shouldShowGenericBackupsMegaphone(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER);
put(Event.VERIFY_BACKUP_KEY, new VerifyBackupKeyReminderSchedule());
put(Event.USE_NEW_ON_DEVICE_BACKUPS, shouldShowUseNewOnDeviceBackupsMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(14)) : NEVER);
}};
@@ -181,8 +188,14 @@ public final class Megaphones {
return buildPnpLaunchMegaphone();
case NEW_LINKED_DEVICE:
return buildNewLinkedDeviceMegaphone(context);
case TURN_ON_SIGNAL_BACKUPS:
return buildTurnOnSignalBackupsMegaphone();
case BACKUPS_GENERIC_UPSELL:
return buildBackupGenericUpsellMegaphone();
case BACKUP_MESSAGE_COUNT_UPSELL:
return buildBackupMessageCountUpsellMegaphone();
case BACKUP_MEDIA_SIZE_UPSELL:
return buildBackupMediaSizeUpsellMegaphone();
case BACKUP_LOW_STORAGE_UPSELL:
return buildBackupLowStorageUpsellMegaphone();
case VERIFY_BACKUP_KEY:
return buildVerifyBackupKeyMegaphone();
case USE_NEW_ON_DEVICE_BACKUPS:
@@ -454,8 +467,8 @@ public final class Megaphones {
.build();
}
public static @NonNull Megaphone buildTurnOnSignalBackupsMegaphone() {
return new Megaphone.Builder(Event.TURN_ON_SIGNAL_BACKUPS, Megaphone.Style.BASIC)
public static @NonNull Megaphone buildBackupGenericUpsellMegaphone() {
return new Megaphone.Builder(Event.BACKUPS_GENERIC_UPSELL, Megaphone.Style.BASIC)
.setImage(R.drawable.backups_megaphone_image)
.setTitle(R.string.TurnOnSignalBackups__title)
.setBody(R.string.TurnOnSignalBackups__body)
@@ -463,11 +476,11 @@ public final class Megaphones {
Intent intent = AppSettingsActivity.remoteBackups(controller.getMegaphoneActivity());
controller.onMegaphoneNavigationRequested(intent);
controller.onMegaphoneSnooze(Event.TURN_ON_SIGNAL_BACKUPS);
controller.onMegaphoneSnooze(Event.BACKUPS_GENERIC_UPSELL);
})
.setSecondaryButton(R.string.TurnOnSignalBackups__not_now, (megaphone, controller) -> {
controller.onMegaphoneToastRequested(controller.getMegaphoneActivity().getString(R.string.TurnOnSignalBackups__toast_not_now));
controller.onMegaphoneSnooze(Event.TURN_ON_SIGNAL_BACKUPS);
controller.onMegaphoneSnooze(Event.BACKUPS_GENERIC_UPSELL);
})
.build();
}
@@ -579,7 +592,7 @@ public final class Megaphones {
return SignalStore.account().isPrimaryDevice() && TextUtils.isEmpty(SignalStore.account().getUsername()) && !SignalStore.uiHints().hasCompletedUsernameOnboarding();
}
private static boolean shouldShowTurnOnBackupsMegaphone(@NonNull Context context) {
private static boolean shouldShowGenericBackupsMegaphone(@NonNull Context context) {
if (!RemoteConfig.backupsMegaphone()) {
return false;
}
@@ -631,6 +644,92 @@ public final class Megaphones {
return System.currentTimeMillis() - lastSeenDonatePrompt;
}
private static boolean shouldShowBackupMessageCountUpsell(@NonNull Context context) {
if (!SignalStore.account().isRegistered() || TextSecurePreferences.isUnauthorizedReceived(context) || SignalStore.account().isLinkedDevice()) {
return false;
}
if (SignalStore.backup().getLatestBackupTier() != null) {
return false;
}
return SignalDatabase.messages().getApproximateTotalMessageCount() > 1000;
}
private static boolean shouldShowBackupMediaSizeUpsell() {
if (!SignalStore.account().isRegistered() || SignalStore.account().isLinkedDevice() || !Environment.Backups.supportsGooglePlayBilling()) {
return false;
}
if (SignalStore.backup().getLatestBackupTier() != MessageBackupTier.FREE) {
return false;
}
return SignalDatabase.attachments().getApproximateTotalMediaSize() > ByteUnit.GIGABYTES.toBytes(1);
}
private static boolean shouldShowBackupLowStorageUpsell(@NonNull Context context) {
if (!SignalStore.account().isRegistered() || TextSecurePreferences.isUnauthorizedReceived(context) || SignalStore.account().isLinkedDevice() || !Environment.Backups.supportsGooglePlayBilling()) {
return false;
}
if (SignalStore.backup().getLatestBackupTier() == MessageBackupTier.PAID) {
return false;
}
long available = DiskUtil.getAvailableSpace(context).getBytes();
long total = DiskUtil.getTotalDiskSize(context).getBytes();
return total > 0 && ((double) available / total) < 0.10;
}
private static @NonNull Megaphone buildBackupMessageCountUpsellMegaphone() {
return new Megaphone.Builder(Event.BACKUP_MESSAGE_COUNT_UPSELL, Megaphone.Style.BASIC)
.setImage(R.drawable.megaphone_backup_message_count)
.setTitle(R.string.BackupMessagesUpsell__title)
.setBody(R.string.BackupMessagesUpsell__body)
.setActionButton(R.string.BackupMessagesUpsell__turn_on, (megaphone, controller) -> {
Intent intent = AppSettingsActivity.remoteBackups(controller.getMegaphoneActivity());
controller.onMegaphoneNavigationRequested(intent);
controller.onMegaphoneSnooze(Event.BACKUP_MESSAGE_COUNT_UPSELL);
})
.setSecondaryButton(R.string.BackupMessagesUpsell__not_now, (megaphone, controller) -> {
controller.onMegaphoneSnooze(Event.BACKUP_MESSAGE_COUNT_UPSELL);
})
.build();
}
private static @NonNull Megaphone buildBackupMediaSizeUpsellMegaphone() {
return new Megaphone.Builder(Event.BACKUP_MEDIA_SIZE_UPSELL, Megaphone.Style.BASIC)
.setImage(R.drawable.megaphone_backup_media_size)
.setTitle(R.string.BackupMediaUpsell__title)
.setBody(R.string.BackupMediaUpsell__body)
.setActionButton(R.string.BackupMediaUpsell__upgrade, (megaphone, controller) -> {
controller.onMegaphoneDialogFragmentRequested(BackupUpsellBottomSheet.create(false));
controller.onMegaphoneSnooze(Event.BACKUP_MEDIA_SIZE_UPSELL);
})
.setSecondaryButton(R.string.BackupMediaUpsell__not_now, (megaphone, controller) -> {
controller.onMegaphoneSnooze(Event.BACKUP_MEDIA_SIZE_UPSELL);
})
.build();
}
private static @NonNull Megaphone buildBackupLowStorageUpsellMegaphone() {
boolean hasBackups = SignalStore.backup().getLatestBackupTier() != null;
return new Megaphone.Builder(Event.BACKUP_LOW_STORAGE_UPSELL, Megaphone.Style.BASIC)
.setImage(R.drawable.megaphone_backup_storage_low)
.setTitle(R.string.BackupStorageUpsell__title)
.setBody(R.string.BackupStorageUpsell__body)
.setActionButton(hasBackups ? R.string.BackupStorageUpsell__upgrade : R.string.BackupStorageUpsell__turn_on, (megaphone, controller) -> {
controller.onMegaphoneDialogFragmentRequested(BackupUpsellBottomSheet.create(true));
controller.onMegaphoneSnooze(Event.BACKUP_LOW_STORAGE_UPSELL);
})
.setSecondaryButton(R.string.BackupStorageUpsell__not_now, (megaphone, controller) -> {
controller.onMegaphoneSnooze(Event.BACKUP_LOW_STORAGE_UPSELL);
})
.build();
}
public enum Event {
PINS_FOR_ALL("pins_for_all"),
@@ -649,7 +748,10 @@ public final class Megaphones {
PNP_LAUNCH("pnp_launch"),
GRANT_FULL_SCREEN_INTENT("grant_full_screen_intent"),
NEW_LINKED_DEVICE("new_linked_device"),
TURN_ON_SIGNAL_BACKUPS("turn_on_signal_backups"),
BACKUPS_GENERIC_UPSELL("turn_on_signal_backups"),
BACKUP_MESSAGE_COUNT_UPSELL("backup_messages_upsell"),
BACKUP_MEDIA_SIZE_UPSELL("backup_media_upsell"),
BACKUP_LOW_STORAGE_UPSELL("backup_storage_upsell"),
VERIFY_BACKUP_KEY("verify_backup_key"),
USE_NEW_ON_DEVICE_BACKUPS("use_new_on_device_backups");
@@ -45,7 +45,7 @@ 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.safety.SafetyNumberBottomSheet.forMessageRecord
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet.forOutgoingMessageRecord
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -153,7 +153,7 @@ class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks {
}
override fun onErrorClicked(messageRecord: MessageRecord) {
forMessageRecord(requireContext(), messageRecord)
forOutgoingMessageRecord(requireContext(), messageRecord)
.show(childFragmentManager)
}
@@ -132,18 +132,13 @@ class ReusedBatchCache : BatchCache() {
}
batchedJobs.clear()
if (threadUpdates.isNotEmpty()) {
if (threadUpdates.isNotEmpty() || mslDeletes.isNotEmpty()) {
SignalDatabase.runInTransaction {
threadUpdates.forEach { flushIncomingMessageInsertThreadUpdate(it) }
}
}
threadUpdates.clear()
if (mslDeletes.isNotEmpty()) {
SignalDatabase.runInTransaction {
mslDeletes.forEach { (key, timestamps) -> flushMslDelete(key.first, key.second, timestamps) }
}
}
threadUpdates.clear()
mslDeletes.clear()
}
}
@@ -188,7 +188,7 @@ object DataMessageProcessor {
message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry)
message.pinMessage != null -> insertResult = handlePinMessage(envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, earlyMessageCacheEntry)
message.unpinMessage != null -> messageId = handleUnpinMessage(envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry)
message.adminDelete != null -> messageId = handleAdminRemoteDelete(envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry)
message.adminDelete != null -> messageId = handleAdminRemoteDelete(context, envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry)
}
SignalTrace.endSection()
@@ -1423,7 +1423,7 @@ object DataMessageProcessor {
return MessageId(targetMessageId)
}
fun handleAdminRemoteDelete(envelope: Envelope, message: DataMessage, senderRecipient: Recipient, threadRecipient: Recipient, earlyMessageCacheEntry: EarlyMessageCacheEntry?): MessageId? {
fun handleAdminRemoteDelete(context: Context, envelope: Envelope, message: DataMessage, senderRecipient: Recipient, threadRecipient: Recipient, earlyMessageCacheEntry: EarlyMessageCacheEntry?): MessageId? {
if (!RemoteConfig.receiveAdminDelete) {
log(envelope.timestamp!!, "Admin delete is not allowed due to remote config.")
return null
@@ -1451,6 +1451,7 @@ object DataMessageProcessor {
return if (targetMessage != null && MessageConstraintsUtil.isValidAdminDeleteReceive(targetMessage, senderRecipient, envelope.serverTimestamp!!, groupRecord)) {
SignalDatabase.messages.markAsRemoteDelete(targetMessage, senderRecipient.id)
AppDependencies.messageNotifier.updateNotification(context, ConversationId.fromMessageRecord(targetMessage))
MessageId(targetMessage.id)
} else if (targetMessage == null) {
warn(envelope.timestamp!!, "[handleAdminRemoteDelete] Could not find matching message! timestamp: $targetSentTimestamp")
@@ -38,9 +38,11 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess.Companion.toAp
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.AlarmSleepTimer
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.SignalTrace
import org.thoughtcrime.securesms.util.asChain
import org.whispersystems.signalservice.api.messages.EnvelopeResponse
import org.whispersystems.signalservice.api.util.SleepTimer
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
@@ -285,9 +287,7 @@ class IncomingMessageObserver(
fun processEnvelope(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long, batchCache: BatchCache): List<FollowUpOperation>? {
return when (envelope.type) {
Envelope.Type.SERVER_DELIVERY_RECEIPT -> {
SignalTrace.beginSection("IncomingMessageObserver#processReceipt")
processReceipt(envelope)
SignalTrace.endSection()
null
}
@@ -397,7 +397,6 @@ class IncomingMessageObserver(
private var sleepTimer: SleepTimer
private val canProcessMessages: Boolean
private val batchCache = ReusedBatchCache()
init {
Log.i(TAG, "Initializing! (${this.hashCode()})")
@@ -451,30 +450,16 @@ class IncomingMessageObserver(
val hasMore = authWebSocket.readMessageBatch(websocketReadTimeout, 30) { batch ->
Log.i(TAG, "Retrieved ${batch.size} envelopes!")
val bufferedStore = BufferedProtocolStore.create()
val startTime = System.currentTimeMillis()
GroupsV2ProcessingLock.acquireGroupProcessingLock().use {
ReentrantSessionLock.INSTANCE.acquire().use {
batch.forEach { response ->
SignalTrace.beginSection("IncomingMessageObserver#perMessageTransaction")
val followUpOperations = SignalDatabase.runInTransaction { db ->
val followUps: List<FollowUpOperation>? = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp, batchCache)
bufferedStore.flushToDisk()
followUps
}
SignalTrace.endSection()
val batchCommitted = processBatchInTransaction(batch)
if (followUpOperations?.isNotEmpty() == true) {
Log.d(TAG, "Running ${followUpOperations.size} follow-up operations...")
val jobs = followUpOperations.mapNotNull { it.run() }
AppDependencies.jobManager.addAllChains(jobs)
}
authWebSocket.sendAck(response)
if (!batchCommitted) {
Log.w(TAG, "Batch transaction rolled back, falling back to per-message processing")
processMessagesIndividually(batch)
}
batchCache.flushAndClear()
}
}
val duration = System.currentTimeMillis() - startTime
@@ -485,7 +470,9 @@ class IncomingMessageObserver(
SignalLocalMetrics.PushWebsocketFetch.onProcessedBatch()
if (!hasMore && !decryptionDrained) {
SignalTrace.endSection()
if (Environment.IS_BENCHMARK) {
SignalTrace.endSection()
}
Log.i(TAG, "Decryptions newly-drained.")
decryptionDrained = true
@@ -528,6 +515,73 @@ class IncomingMessageObserver(
Log.w(TAG, "Terminated! (${this.hashCode()})")
}
/**
* Attempts to process the entire batch in a single transaction for performance.
*
* @return true if the transaction committed, false if it the batch was rolled back.
*/
private fun processBatchInTransaction(batch: List<EnvelopeResponse>): Boolean {
val allFollowUpOperations = mutableListOf<FollowUpOperation>()
val bufferedStore = BufferedProtocolStore.create()
val batchCache = ReusedBatchCache()
val committed = SignalDatabase.tryRunInTransaction {
batch.forEach { response ->
SignalTrace.beginSection("IncomingMessageObserver#perMessageTransaction")
val followUps = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp, batchCache)
bufferedStore.flushToDisk()
SignalTrace.endSection()
if (followUps?.isNotEmpty() == true) {
allFollowUpOperations += followUps
}
}
}
if (committed) {
batchCache.flushAndClear()
if (allFollowUpOperations.isNotEmpty()) {
Log.d(TAG, "Running ${allFollowUpOperations.size} follow-up operations...")
val jobs = allFollowUpOperations.mapNotNull { it.run() }
AppDependencies.jobManager.addAllChains(jobs)
}
batch.forEach { response ->
authWebSocket.sendAck(response)
}
}
return committed
}
/**
* If something prevented us from processing the entire batch in a single transaction, we process each message individually.
*/
private fun processMessagesIndividually(batch: List<EnvelopeResponse>) {
val bufferedStore = BufferedProtocolStore.create()
val batchCache = ReusedBatchCache()
batch.forEach { response ->
SignalTrace.beginSection("IncomingMessageObserver#perMessageTransaction")
val followUpOperations = SignalDatabase.runInTransaction {
val followUps = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp, batchCache)
bufferedStore.flushToDisk()
followUps
}
SignalTrace.endSection()
if (followUpOperations?.isNotEmpty() == true) {
val jobs = followUpOperations.mapNotNull { it.run() }
AppDependencies.jobManager.addAllChains(jobs)
}
authWebSocket.sendAck(response)
}
batchCache.flushAndClear()
}
override fun uncaughtException(t: Thread, e: Throwable) {
Log.w(TAG, "Uncaught exception in message thread!", e)
}
@@ -259,7 +259,7 @@ object SyncMessageProcessor {
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(getSyncMessageDestination(sent))
}
dataMessage.adminDelete != null -> {
DataMessageProcessor.handleAdminRemoteDelete(envelope, dataMessage, senderRecipient, threadRecipient, earlyMessageCacheEntry)
DataMessageProcessor.handleAdminRemoteDelete(context, envelope, dataMessage, senderRecipient, threadRecipient, earlyMessageCacheEntry)
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(getSyncMessageDestination(sent))
}
else -> threadId = handleSynchronizeSentTextMessage(sent, envelope.timestamp!!)
@@ -1893,6 +1893,11 @@ object SyncMessageProcessor {
return -1
}
if (targetMessage.isRemoteDelete) {
warn(envelope.timestamp!!, "Cannot pin deleted message")
return -1
}
val targetMessageId = (targetMessage as? MmsMessageRecord)?.latestRevisionId?.id ?: targetMessage.id
val duration = if (pinMessage.pinDurationForever == true) MessageTable.PIN_FOREVER else pinMessage.pinDurationSeconds!!.toLong()
val outgoingMessage = OutgoingMessage.pinMessage(
@@ -217,12 +217,12 @@ public class LegacyMigrationJob extends MigrationJob {
// }
// }
if (lastSeenVersion < COLOR_MIGRATION) {
long startTime = System.currentTimeMillis();
//noinspection deprecation
SignalDatabase.recipients().updateSystemContactColors();
Log.i(TAG, "Color migration took " + (System.currentTimeMillis() - startTime) + " ms");
}
// if (lastSeenVersion < COLOR_MIGRATION) {
// long startTime = System.currentTimeMillis();
// //noinspection deprecation
// SignalDatabase.recipients().updateSystemContactColors();
// Log.i(TAG, "Color migration took " + (System.currentTimeMillis() - startTime) + " ms");
// }
if (lastSeenVersion < UNIDENTIFIED_DELIVERY) {
Log.i(TAG, "Scheduling UD attributes refresh.");
@@ -13,6 +13,7 @@ import androidx.annotation.WorkerThread;
import org.signal.core.util.CursorUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -27,9 +28,31 @@ public final class DoNotDisturbUtil {
private DoNotDisturbUtil() {
}
/**
* Checks whether the user should be disturbed with a call from the given recipient,
* taking into account the recipient's mute and call notification settings as well as
* the system Do Not Disturb state.
*
* For group recipients, only the system interruption filter is checked (no contact priority).
* For 1:1 recipients, the full DND policy including contact priority is evaluated.
*/
@WorkerThread
@SuppressLint("SwitchIntDef")
public static boolean shouldDisturbUserWithCall(@NonNull Context context) {
public static boolean shouldDisturbUserWithCall(@NonNull Context context, @NonNull Recipient recipient) {
if (recipient.isMuted() && recipient.getCallNotificationSetting() == RecipientTable.NotificationSetting.DO_NOT_NOTIFY) {
return false;
}
if (recipient.isGroup()) {
return checkSystemDnd(context);
} else {
return checkSystemDndWithContactPriority(context, recipient);
}
}
@WorkerThread
@SuppressLint("SwitchIntDef")
private static boolean checkSystemDnd(@NonNull Context context) {
NotificationManager notificationManager = ServiceUtil.getNotificationManager(context);
switch (notificationManager.getCurrentInterruptionFilter()) {
@@ -43,7 +66,7 @@ public final class DoNotDisturbUtil {
@WorkerThread
@SuppressLint("SwitchIntDef")
public static boolean shouldDisturbUserWithCall(@NonNull Context context, @NonNull Recipient recipient) {
private static boolean checkSystemDndWithContactPriority(@NonNull Context context, @NonNull Recipient recipient) {
NotificationManager notificationManager = ServiceUtil.getNotificationManager(context);
switch (notificationManager.getCurrentInterruptionFilter()) {
@@ -167,13 +167,13 @@ object NotificationStateProvider {
isUnreadMessage &&
!messageRecord.isOutgoing &&
isGroupStoryReply &&
(isParentStorySentBySelf || messageRecord.hasSelfMentionOrQuoteOfSelf() || (hasSelfRepliedToStory && !messageRecord.isStoryReaction()))
(isParentStorySentBySelf || messageRecord.hasGroupQuoteOrSelfMention() || (hasSelfRepliedToStory && !messageRecord.isStoryReaction()))
fun includeMessage(notificationProfile: NotificationProfile?): MessageInclusion {
return if (isUnreadIncoming || stickyThread || isNotifiableGroupStoryMessage || isIncomingMissedCall) {
if (threadRecipient.isMuted && (threadRecipient.isDoNotNotifyMentions || !messageRecord.hasSelfMentionOrQuoteOfSelf())) {
if (threadRecipient.isMuted && !breaksThroughMute()) {
MessageInclusion.MUTE_FILTERED
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.hasSelfMentionOrQuoteOfSelf())) {
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.hasGroupQuoteOrSelfMention())) {
MessageInclusion.PROFILE_FILTERED
} else {
MessageInclusion.INCLUDE
@@ -183,6 +183,19 @@ object NotificationStateProvider {
}
}
private fun breaksThroughMute(): Boolean {
return when {
isIncomingMissedCall -> threadRecipient.callNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY
messageRecord.hasSelfMention() -> threadRecipient.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY
messageRecord.isQuoteOfSelf() -> threadRecipient.replyNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY
else -> false
}
}
private fun MessageRecord.isQuoteOfSelf(): Boolean {
return this is MmsMessageRecord && quote?.author == Recipient.self().id
}
fun includeReaction(reaction: ReactionRecord, notificationProfile: NotificationProfile?): MessageInclusion {
return if (threadRecipient.isMuted) {
MessageInclusion.MUTE_FILTERED
@@ -207,10 +220,10 @@ object NotificationStateProvider {
}
}
private val Recipient.isDoNotNotifyMentions: Boolean
get() = mentionSetting == RecipientTable.MentionSetting.DO_NOT_NOTIFY
private fun MessageRecord.hasSelfMentionOrQuoteOfSelf(): Boolean {
private fun MessageRecord.hasGroupQuoteOrSelfMention(): Boolean {
if (!threadRecipient.isGroup) {
return false
}
return hasSelfMention() || (this is MmsMessageRecord && quote?.author == Recipient.self().id)
}
}
@@ -28,8 +28,8 @@ import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColors.Id.Auto
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting
import org.thoughtcrime.securesms.database.RecipientTable.MissingRecipientException
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState
import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState
import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.phonenumbers.NumberUtil
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient.Companion.external
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.UsernameUtil.isValidUsernameForSearch
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
@@ -103,7 +104,9 @@ class Recipient(
private val sealedSenderAccessModeValue: SealedSenderAccessMode = SealedSenderAccessMode.UNKNOWN,
private val capabilities: RecipientRecord.Capabilities = RecipientRecord.Capabilities.UNKNOWN,
val storageId: ByteArray? = null,
val mentionSetting: MentionSetting = MentionSetting.ALWAYS_NOTIFY,
val mentionSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
private val callNotificationSettingValue: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
private val replyNotificationSettingValue: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
private val wallpaperValue: ChatWallpaper? = null,
private val chatColorsValue: ChatColors? = null,
val avatarColor: AvatarColor = AvatarColor.UNKNOWN,
@@ -329,6 +332,14 @@ class Recipient(
/** The notification channel, if both set and supported by the system. Otherwise null. */
val notificationChannel: String? = if (!NotificationChannels.supported()) null else notificationChannelValue
/** Whether calls should break through mute for this recipient. */
val callNotificationSetting: NotificationSetting
get() = if (RemoteConfig.internalUser) callNotificationSettingValue else NotificationSetting.ALWAYS_NOTIFY
/** Whether replies should break through mute for this recipient. Only applicable to groups. */
val replyNotificationSetting: NotificationSetting
get() = if (groupIdValue == null) NotificationSetting.DO_NOT_NOTIFY else if (RemoteConfig.internalUser) replyNotificationSettingValue else mentionSetting
/** The state around whether we can send sealed sender to this user. */
val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) {
SealedSenderAccessMode.DISABLED
@@ -810,6 +821,8 @@ class Recipient(
notificationChannelValue == other.notificationChannelValue &&
sealedSenderAccessModeValue == other.sealedSenderAccessModeValue &&
mentionSetting == other.mentionSetting &&
callNotificationSettingValue == other.callNotificationSettingValue &&
replyNotificationSettingValue == other.replyNotificationSettingValue &&
wallpaperValue == other.wallpaperValue &&
chatColorsValue == other.chatColorsValue &&
avatarColor == other.avatarColor &&
@@ -185,6 +185,8 @@ object RecipientCreator {
capabilities = record.capabilities,
storageId = record.storageId,
mentionSetting = record.mentionSetting,
callNotificationSettingValue = record.callNotificationSetting,
replyNotificationSettingValue = record.replyNotificationSetting,
wallpaperValue = record.wallpaper?.validate(),
chatColorsValue = record.chatColors,
avatarColor = avatarColor ?: record.avatarColor,
@@ -111,7 +111,9 @@ class RecipientBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
val avatar: AvatarView = view.findViewById(R.id.rbs_recipient_avatar)
val fullName: TextView = view.findViewById(R.id.rbs_full_name)
val memberLabelView: MemberLabelPillView = view.findViewById(R.id.rbs_member_label)
val memberLabelView: MemberLabelPillView = view.findViewById<MemberLabelPillView>(R.id.rbs_member_label).apply {
style = MemberLabelPillView.Style(maxLines = Int.MAX_VALUE)
}
val aboutView: TextView = view.findViewById(R.id.rbs_about)
val nickname: TextView = view.findViewById(R.id.rbs_nickname_button)
val blockButton: TextView = view.findViewById(R.id.rbs_block_button)
@@ -5,17 +5,29 @@
package org.thoughtcrime.securesms.registration.ui.restore
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.backup.v2.local.proto.Metadata
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import java.util.concurrent.atomic.AtomicInteger
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class EnterBackupKeyViewModel : ViewModel() {
@@ -30,6 +42,8 @@ class EnterBackupKeyViewModel : ViewModel() {
)
)
private val verifyGeneration = AtomicInteger(0)
var backupKey by mutableStateOf("")
private set
@@ -49,6 +63,56 @@ class EnterBackupKeyViewModel : ViewModel() {
}
}
fun verifyLocalBackupKey(selectedTimestamp: Long) {
if (!state.value.backupKeyValid) {
return
}
val generation = verifyGeneration.incrementAndGet()
store.update { it.copy(backupKeyValid = false) }
viewModelScope.launch(Dispatchers.IO) {
val result = verifyKey(selectedTimestamp)
if (verifyGeneration.get() == generation) {
if (result) {
store.update { it.copy(backupKeyValid = true) }
} else {
store.update { it.copy(aepValidationError = AccountEntropyPoolVerification.AEPValidationError.Incorrect) }
}
}
}
}
private fun verifyKey(selectedTimestamp: Long): Boolean {
try {
val aep = AccountEntropyPool.parseOrNull(backupKey) ?: return false
val dirUri = SignalStore.backup.newLocalBackupsDirectory ?: return false
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(dirUri)) ?: return false
val snapshot = archiveFileSystem.listSnapshots().firstOrNull { it.timestamp == selectedTimestamp } ?: return false
val snapshotFs = SnapshotFileSystem(AppDependencies.application, snapshot.file)
val metadata = snapshotFs.metadataInputStream()?.use { Metadata.ADAPTER.decode(it) } ?: return false
val encryptedBackupId = metadata.backupId ?: return false
val messageBackupKey = aep.deriveMessageBackupKey()
val metadataKey = messageBackupKey.deriveLocalBackupMetadataKey()
val iv = encryptedBackupId.iv.toByteArray()
val backupIdCipher = encryptedBackupId.encryptedId.toByteArray()
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
val decryptedBackupId = cipher.doFinal(backupIdCipher)
val expectedBackupId = messageBackupKey.deriveBackupId(SignalStore.account.requireAci())
return decryptedBackupId.contentEquals(expectedBackupId.value)
} catch (e: Exception) {
Log.w(TAG, "Failed to verify local backup key", e)
return false
}
}
fun registering() {
store.update { it.copy(isRegistering = true) }
}
@@ -27,23 +27,30 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportCallbacks
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel
import org.thoughtcrime.securesms.restore.RestoreActivity
import kotlin.math.max
/**
@@ -62,6 +69,11 @@ class RestoreLocalBackupActivity : BaseActivity() {
}
private val viewModel: RestoreLocalBackupActivityViewModel by viewModels()
private val contactSupportViewModel: ContactSupportViewModel<Unit> by viewModels()
private val finishActivity by lazy {
intent.getBooleanExtra(KEY_FINISH, false)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -73,26 +85,51 @@ class RestoreLocalBackupActivity : BaseActivity() {
when (state.restorePhase) {
RestorePhase.COMPLETE -> {
startActivity(MainActivity.clearTop(this@RestoreLocalBackupActivity))
if (intent.getBooleanExtra(KEY_FINISH, false)) {
if (finishActivity) {
finishAffinity()
}
}
RestorePhase.FAILED -> {
Toast.makeText(this@RestoreLocalBackupActivity, getString(R.string.RestoreLocalBackupActivity__backup_restore_failed), Toast.LENGTH_LONG).show()
}
else -> Unit
}
}
val contactSupportState by contactSupportViewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
SignalTheme {
RestoreLocalBackupScreen(state = state)
RestoreLocalBackupScreen(
state = state,
onContactSupportClick = contactSupportViewModel::showContactSupport,
onFailureDialogConfirm = {
if (finishActivity) {
viewModel.resetRestoreState()
startActivity(RestoreActivity.getRestoreIntent(context))
}
// User invocation here should always finish, it just shouldn't route back to RestoreActivity.
supportFinishAfterTransition()
},
contactSupportState = contactSupportState,
contactSupportCallbacks = contactSupportViewModel
)
}
}
}
}
@Composable
private fun RestoreLocalBackupScreen(state: RestoreLocalBackupScreenState) {
private fun RestoreLocalBackupScreen(
state: RestoreLocalBackupScreenState,
onFailureDialogConfirm: () -> Unit,
onContactSupportClick: () -> Unit,
contactSupportState: ContactSupportViewModel.ContactSupportState<Unit>,
contactSupportCallbacks: ContactSupportCallbacks
) {
val density = LocalDensity.current
var headerHeightPx by remember { mutableIntStateOf(0) }
var contentHeightPx by remember { mutableIntStateOf(0) }
@@ -177,6 +214,33 @@ private fun RestoreLocalBackupScreen(state: RestoreLocalBackupScreenState) {
}
}
}
if (state.restorePhase == RestorePhase.FAILED) {
var wasContactSupportShown by remember { mutableStateOf(false) }
LaunchedEffect(contactSupportState.show) {
if (wasContactSupportShown && !contactSupportState.show) {
onFailureDialogConfirm()
}
wasContactSupportShown = contactSupportState.show
}
if (!contactSupportState.show) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RestoreLocalBackupActivity__cant_restore_backup),
body = stringResource(R.string.RestoreLocalBackupActivity__error_occurred_while_restoring),
confirm = stringResource(android.R.string.ok),
onConfirm = onFailureDialogConfirm,
dismiss = stringResource(R.string.RestoreLocalBackupActivity__contact_support),
onDeny = onContactSupportClick
)
} else {
ContactSupportDialog(
showInProgress = contactSupportState.showAsProgress,
callbacks = contactSupportCallbacks
)
}
}
}
}
@@ -184,6 +248,12 @@ private fun RestoreLocalBackupScreen(state: RestoreLocalBackupScreenState) {
@Composable
private fun RestoreLocalBackupScreenPreview() {
Previews.Preview {
RestoreLocalBackupScreen(state = RestoreLocalBackupScreenState())
RestoreLocalBackupScreen(
state = RestoreLocalBackupScreenState(),
onFailureDialogConfirm = {},
onContactSupportClick = {},
contactSupportState = ContactSupportViewModel.ContactSupportState(),
contactSupportCallbacks = ContactSupportCallbacks.Empty
)
}
}
@@ -133,6 +133,10 @@ class RestoreLocalBackupActivityViewModel : ViewModel() {
}
}
}
fun resetRestoreState() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState(decisionState = RestoreDecisionState.State.START)
}
}
data class RestoreLocalBackupScreenState(
@@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.keyvalue.skippedRestoreChoice
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registration.ui.restore.RestoreMethod
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.util.Environment
import org.whispersystems.signalservice.api.provisioning.RestoreMethod as ApiRestoreMethod
/**
@@ -59,7 +60,11 @@ class RestoreViewModel : ViewModel() {
fun getAvailableRestoreMethods(): List<RestoreMethod> {
if (SignalStore.registration.isOtherDeviceAndroid || SignalStore.registration.restoreDecisionState.skippedRestoreChoice) {
val methods = mutableListOf(RestoreMethod.FROM_LOCAL_BACKUP_V1)
val methods = if (Environment.Backups.isNewFormatSupportedForLocalBackup()) {
mutableListOf(RestoreMethod.FROM_LOCAL_BACKUP_V2)
} else {
mutableListOf(RestoreMethod.FROM_LOCAL_BACKUP_V1)
}
if (SignalStore.registration.isOtherDeviceAndroid && SignalStore.registration.restoreDecisionState.includeDeviceToDeviceTransfer) {
methods.add(0, RestoreMethod.FROM_OLD_DEVICE)
@@ -0,0 +1,132 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.restore.local
import android.content.Context
import android.net.Uri
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.restore.EnterBackupKeyViewModel
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupCallback
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupNavDisplay
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupViewModel
import org.thoughtcrime.securesms.registration.ui.restore.local.SelectableBackup
import org.thoughtcrime.securesms.restore.RestoreViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Post Registration restore fragment for V2 backups.
*/
class PostRegistrationRestoreLocalBackupFragment : ComposeFragment() {
companion object {
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
}
private val sharedViewModel: RestoreViewModel by activityViewModels()
private val restoreLocalBackupViewModel by viewModels<RestoreLocalBackupViewModel>()
private val enterBackupKeyViewModel by viewModels<EnterBackupKeyViewModel>()
@Composable
override fun FragmentContent() {
val state by restoreLocalBackupViewModel.state.collectAsStateWithLifecycle()
val enterBackupKeyState by enterBackupKeyViewModel.state.collectAsStateWithLifecycle()
SignalTheme {
val activity = LocalActivity.current as FragmentActivity
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides activity) {
RestoreLocalBackupNavDisplay(
state = state,
callback = remember { Callbacks() },
isRegistrationInProgress = false,
enterBackupKeyState = enterBackupKeyState,
backupKey = enterBackupKeyViewModel.backupKey
)
}
}
}
private inner class Callbacks : RestoreLocalBackupCallback {
override fun setSelectedBackup(backup: SelectableBackup) {
restoreLocalBackupViewModel.setSelectedBackup(backup)
}
override fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
return restoreLocalBackupViewModel.setSelectedBackupDirectory(context, uri)
}
override fun displaySkipRestoreWarning() {
restoreLocalBackupViewModel.displaySkipRestoreWarning()
}
override fun clearDialog() {
restoreLocalBackupViewModel.clearDialog()
}
override fun skipRestore() {
sharedViewModel.skipRestore()
viewLifecycleOwner.lifecycleScope.launch {
sharedViewModel.performStorageServiceAccountRestoreIfNeeded()
withContext(Dispatchers.Main) {
startActivity(MainActivity.clearTop(requireContext()))
activity?.finish()
}
}
}
override fun submitBackupKey() {
val aep = AccountEntropyPool.parseOrNull(enterBackupKeyViewModel.backupKey) ?: return
SignalStore.account.restoreAccountEntropyPool(aep)
val selectedTimestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: -1L
SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = selectedTimestamp
startActivity(RestoreLocalBackupActivity.getIntent(requireContext()))
requireActivity().supportFinishAfterTransition()
}
override fun routeToLegacyBackupRestoration(uri: Uri) {
sharedViewModel.setBackupFileUri(uri)
findNavController().safeNavigate(PostRegistrationRestoreLocalBackupFragmentDirections.restoreLocalV1Backup())
}
override fun onBackupKeyChanged(key: String) {
enterBackupKeyViewModel.updateBackupKey(key)
val timestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: return
enterBackupKeyViewModel.verifyLocalBackupKey(timestamp)
}
override fun clearRegistrationError() {
enterBackupKeyViewModel.clearRegistrationError()
}
override fun onBackupKeyHelp() {
CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL)
}
}
}
@@ -97,6 +97,7 @@ class RestoreLocalBackupViewModel(fileBackupUri: Uri) : ViewModel() {
SignalStore.registration.localRegistrationMetadata = null
RegistrationUtil.maybeMarkRegistrationComplete()
SignalStore.misc.needsUsernameRestore = true
AppDependencies.jobManager.add(ReclaimUsernameAndLinkJob())
}
@@ -98,7 +98,7 @@ class SelectRestoreMethodFragment : ComposeFragment() {
}
RestoreMethod.FROM_OLD_DEVICE -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToDeviceTransfer())
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToLocalBackupRestore())
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToLocalBackupRestoreV2())
}
}
}
@@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.util.Preconditions
@@ -73,14 +74,14 @@ object SafetyNumberBottomSheet {
}
/**
* Create a factory to generate a sheet for the given message record. This will try
* Create a factory to generate a sheet for an outgoing message record. This will try
* to resend the message automatically when the user confirms.
*
* @param context Not held on to, so any context is fine.
* @param messageRecord The message record containing failed identities.
*/
@JvmStatic
fun forMessageRecord(context: Context, messageRecord: MessageRecord): Factory {
fun forOutgoingMessageRecord(context: Context, messageRecord: MessageRecord): Factory {
val args = SafetyNumberBottomSheetArgs(
untrustedRecipients = messageRecord.identityKeyMismatches.map { it.recipientId },
destinations = getDestinationFromRecord(messageRecord),
@@ -90,6 +91,21 @@ object SafetyNumberBottomSheet {
return SheetFactory(args)
}
/**
* Create a factory to generate a sheet for an incoming message record. This will try
* to resend the message automatically when the user confirms.
*/
@JvmStatic
fun forIncomingMessageRecord(messageRecord: MessageRecord, conversationRecipient: Recipient): Factory {
val args = SafetyNumberBottomSheetArgs(
untrustedRecipients = messageRecord.identityKeyMismatches.map { it.recipientId },
destinations = listOf(ContactSearchKey.RecipientSearchKey(conversationRecipient.id, false)),
messageId = MessageId(messageRecord.id)
)
return SheetFactory(args)
}
/**
* Create a factory to generate a sheet for the given identity records and destinations.
*
@@ -13,6 +13,8 @@ import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.notifications.DoNotDisturbUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.signal.core.util.Util;
@@ -116,8 +118,11 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
boolean isRemoteVideoOffer = currentState.getCallSetupState(remotePeer).isRemoteVideoOffer();
Recipient recipient = remotePeer.getRecipient();
webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer, isRemoteVideoOffer);
if (DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient)) {
webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer, isRemoteVideoOffer);
}
webRtcInteractor.retrieveTurnServers(remotePeer);
webRtcInteractor.initializeAudioForCall();
@@ -366,6 +366,19 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handleSendRemoteMuteRequest(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) {
Log.i(tag, "handleSendRemoteMuteRequest():");
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
try {
groupCall.sendRemoteMuteRequest(participant.getCallParticipantId().demuxId);
} catch (CallException e) {
Log.w(tag, "Failed to send remote mute request.", e);
}
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handleGroupCallSpeechEvent(@NonNull WebRtcServiceState currentState, @NonNull GroupCall.SpeechEvent speechEvent) {
Log.i(tag, "handleGroupCallSpeechEvent :: " + speechEvent.name());
@@ -240,18 +240,23 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor {
CallTable.Direction.INCOMING,
CallTable.Event.ONGOING);
if (!shouldDisturbUserWithCall) {
Log.i(TAG, "Silently ignoring call due to mute settings.");
return currentState.builder()
.changeCallInfoState()
.callState(WebRtcViewModel.State.CALL_INCOMING)
.build();
}
if (shouldDisturbUserWithCall) {
webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE);
boolean started = webRtcInteractor.startWebRtcCallActivityIfPossible();
if (!started) {
Log.i(TAG, "Unable to start call activity due to OS version or not being in the foreground");
AppForegroundObserver.addListener(webRtcInteractor.getForegroundListener());
}
webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE);
boolean started = webRtcInteractor.startWebRtcCallActivityIfPossible();
if (!started) {
Log.i(TAG, "Unable to start call activity due to OS version or not being in the foreground");
AppForegroundObserver.addListener(webRtcInteractor.getForegroundListener());
}
boolean isCallNotificationsEnabled = SignalStore.settings().isCallNotificationsEnabled() && NotificationChannels.getInstance().areNotificationsEnabled();
if (shouldDisturbUserWithCall && isCallNotificationsEnabled) {
if (isCallNotificationsEnabled) {
Uri ringtone = recipient.resolve().getCallRingtone();
RecipientTable.VibrateState vibrateState = recipient.resolve().getCallVibrate();
@@ -121,33 +121,36 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState, RemotePeer.GROUP_CALL_ID.longValue());
webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, remotePeerGroup, true);
webRtcInteractor.initializeAudioForCall();
boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext());
if (shouldDisturbUserWithCall) {
boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient.resolve());
if (!shouldDisturbUserWithCall) {
Log.i(TAG, "Silently ignoring group ring due to mute settings.");
} else {
webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, remotePeerGroup, true);
webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE);
boolean started = webRtcInteractor.startWebRtcCallActivityIfPossible();
if (!started) {
Log.i(TAG, "Unable to start call activity due to OS version or not being in the foreground");
AppForegroundObserver.addListener(webRtcInteractor.getForegroundListener());
}
}
boolean isCallNotificationsEnabled = SignalStore.settings().isCallNotificationsEnabled() && NotificationChannels.getInstance().areNotificationsEnabled();
if (shouldDisturbUserWithCall && isCallNotificationsEnabled) {
Uri ringtone = recipient.resolve().getCallRingtone();
RecipientTable.VibrateState vibrateState = recipient.resolve().getCallVibrate();
boolean isCallNotificationsEnabled = SignalStore.settings().isCallNotificationsEnabled() && NotificationChannels.getInstance().areNotificationsEnabled();
if (isCallNotificationsEnabled) {
Uri ringtone = recipient.resolve().getCallRingtone();
RecipientTable.VibrateState vibrateState = recipient.resolve().getCallVibrate();
if (ringtone == null) {
ringtone = SignalStore.settings().getCallRingtone();
if (ringtone == null) {
ringtone = SignalStore.settings().getCallRingtone();
}
webRtcInteractor.startIncomingRinger(ringtone, vibrateState == RecipientTable.VibrateState.ENABLED || (vibrateState == RecipientTable.VibrateState.DEFAULT && SignalStore.settings().isCallVibrateEnabled()));
}
webRtcInteractor.startIncomingRinger(ringtone, vibrateState == RecipientTable.VibrateState.ENABLED || (vibrateState == RecipientTable.VibrateState.DEFAULT && SignalStore.settings().isCallVibrateEnabled()));
webRtcInteractor.registerPowerButtonReceiver();
}
webRtcInteractor.registerPowerButtonReceiver();
return currentState.builder()
.changeCallSetupState(RemotePeer.GROUP_CALL_ID)
.isRemoteVideoOffer(true)

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