mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-09 09:40:14 +01:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8737995fa | |||
| 1bbefea857 | |||
| 143630c41b | |||
| 577eaa1eae | |||
| 316b071c81 | |||
| 5a6f55c0a8 | |||
| e008a50acc | |||
| 41c3913482 | |||
| 803ff76678 | |||
| 309081437a | |||
| 5f152b73c2 | |||
| f8d3336a1e | |||
| dc1fdffe6a | |||
| 622d9c909f | |||
| 4e3ef19c1f | |||
| b054a30fa7 | |||
| 7266c24354 | |||
| 5ec2877bcc | |||
| 0d93446c7d | |||
| 1e395ab416 | |||
| 0acb5ac7cd | |||
| 3b18b5d2b7 | |||
| 16e63a061d | |||
| a6c8b940c9 | |||
| 74d9e3248b | |||
| 3af8b6050c | |||
| da966753a1 | |||
| 0ad4b3f73e | |||
| e8d072d4be | |||
| b0eed4a095 | |||
| ba720efe61 | |||
| e23d575460 | |||
| 7fbcd17759 | |||
| a95ebb2158 | |||
| 8a36425cac | |||
| 4261ed39dc | |||
| ca37a884fd | |||
| 9fbb7683bc | |||
| 42e275ef0a | |||
| 19ece12e93 | |||
| 3ef0d3e4a3 | |||
| 602ea46b8b | |||
| 95c0bc6052 | |||
| bd4ce1788c | |||
| 20d16a8433 | |||
| db4c11cd53 | |||
| f439e1f8e3 | |||
| 080b1aab83 | |||
| 61ba2ac97a | |||
| 7eebb38eda | |||
| 43e7d65af5 | |||
| 386d8bb312 | |||
| 3fbd72092c | |||
| 4e5b15cd88 |
+2
-15
@@ -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(
|
||||
|
||||
+28
-8
@@ -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.")
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+13
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+127
@@ -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());
|
||||
}
|
||||
|
||||
+15
-1
@@ -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")
|
||||
}
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+30
-8
@@ -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(
|
||||
|
||||
+10
@@ -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"))
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
+8
-1
@@ -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()
|
||||
|
||||
+11
-7
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+57
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+59
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+309
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+23
@@ -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
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ class SoundsAndNotificationsSettingsViewModel(
|
||||
repository.setMuteUntil(recipientId, 0L)
|
||||
}
|
||||
|
||||
fun setMentionSetting(mentionSetting: RecipientTable.MentionSetting) {
|
||||
fun setMentionSetting(mentionSetting: RecipientTable.NotificationSetting) {
|
||||
repository.setMentionSetting(recipientId, mentionSetting)
|
||||
}
|
||||
|
||||
|
||||
+102
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
-14
@@ -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
|
||||
|
||||
+2
-1
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+235
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -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() {
|
||||
|
||||
+12
-1
@@ -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()
|
||||
|
||||
+21
-1
@@ -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)
|
||||
|
||||
+11
-8
@@ -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())
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+11
-6
@@ -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)
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ class NewDeviceTransferViewModel : ViewModel() {
|
||||
SignalStore.registration.localRegistrationMetadata = null
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
|
||||
SignalStore.misc.needsUsernameRestore = true
|
||||
AppDependencies.jobManager.add(ReclaimUsernameAndLinkJob())
|
||||
}
|
||||
|
||||
|
||||
+153
@@ -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()
|
||||
}
|
||||
+31
-9
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+15
-6
@@ -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(
|
||||
|
||||
+42
-14
@@ -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)
|
||||
|
||||
|
||||
+9
-9
@@ -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");
|
||||
|
||||
|
||||
+2
-2
@@ -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()) {
|
||||
|
||||
+20
-7
@@ -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,
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+64
@@ -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) }
|
||||
}
|
||||
|
||||
+74
-4
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+132
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -97,6 +97,7 @@ class RestoreLocalBackupViewModel(fileBackupUri: Uri) : ViewModel() {
|
||||
SignalStore.registration.localRegistrationMetadata = null
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
|
||||
SignalStore.misc.needsUsernameRestore = true
|
||||
AppDependencies.jobManager.add(ReclaimUsernameAndLinkJob())
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
*
|
||||
|
||||
+6
-1
@@ -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();
|
||||
|
||||
|
||||
+13
@@ -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());
|
||||
|
||||
+13
-8
@@ -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();
|
||||
|
||||
|
||||
+16
-13
@@ -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
Reference in New Issue
Block a user