mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5bb65c19 | ||
|
|
501f3466a2 | ||
|
|
84c713c2f7 | ||
|
|
303c2ea14a | ||
|
|
109f651681 | ||
|
|
95c9776b4d | ||
|
|
89e6479021 | ||
|
|
e6cb2a9273 | ||
|
|
636f6a338e | ||
|
|
41ba3383b2 | ||
|
|
9095ddaf19 | ||
|
|
b4802c4bf6 | ||
|
|
eb72b88a16 | ||
|
|
be933648b2 | ||
|
|
d4588d738f | ||
|
|
f4cca5ecc1 | ||
|
|
605b85455b | ||
|
|
18c7dbca08 | ||
|
|
55040091af | ||
|
|
24c8501985 | ||
|
|
5f5e0963e1 | ||
|
|
4a163167e0 | ||
|
|
e690c54f7c | ||
|
|
1a39119c2b | ||
|
|
3cd86182db | ||
|
|
c89a3a2bf9 | ||
|
|
908ca124f1 | ||
|
|
5640e9c9b8 | ||
|
|
6d84ea984d | ||
|
|
47201f4955 | ||
|
|
3f5a4ebf7b | ||
|
|
e0d56bfadf | ||
|
|
b9e0d9978b | ||
|
|
ec76372e4d | ||
|
|
fd902159ee | ||
|
|
f16405fabf | ||
|
|
bf4aa9cae9 | ||
|
|
ae8b8bbe7c | ||
|
|
443463aca8 | ||
|
|
b300c911d7 | ||
|
|
6196fb4f44 | ||
|
|
4ecd3ec052 | ||
|
|
b10a57de63 | ||
|
|
3c27a690fd | ||
|
|
dcbd4a3fc4 | ||
|
|
043b7b0a3d | ||
|
|
1862dded65 | ||
|
|
75a53974a4 | ||
|
|
21138b9190 | ||
|
|
8fbfc40ed5 | ||
|
|
63ab448a27 | ||
|
|
d88c8baa83 | ||
|
|
8d931391db | ||
|
|
19afd5c0e6 | ||
|
|
5a9c546dac | ||
|
|
e288b8b429 | ||
|
|
9ddc914cac | ||
|
|
17e7b1735f | ||
|
|
0b27c42e89 | ||
|
|
b15b50798a | ||
|
|
7b7b6a32ee | ||
|
|
4fc516c84f | ||
|
|
76e92f29b9 | ||
|
|
55617c18f0 | ||
|
|
ef05f33f08 | ||
|
|
c25ce2bcdd | ||
|
|
9ed921f58c | ||
|
|
56a4ccb96d | ||
|
|
a8e65619d9 | ||
|
|
123b88e032 | ||
|
|
c268625f52 | ||
|
|
74f9f39656 | ||
|
|
9ddc600972 | ||
|
|
d903bcf2b1 | ||
|
|
19558c5325 | ||
|
|
9d545412a5 | ||
|
|
7301dda5d1 | ||
|
|
c88c565af3 | ||
|
|
f932ea9f1f | ||
|
|
77e2d58dea | ||
|
|
d261f3ebf5 | ||
|
|
9f69ffbb88 | ||
|
|
ab781cab8a | ||
|
|
6d843a9725 | ||
|
|
a387d63b77 | ||
|
|
37544aa8b7 | ||
|
|
963a72a660 | ||
|
|
c316381159 | ||
|
|
3c44d90da7 | ||
|
|
90201a464d | ||
|
|
5f8eaa4f1c | ||
|
|
d6446d2954 | ||
|
|
d763baa270 | ||
|
|
ea70d68ecc | ||
|
|
26cb17e25c | ||
|
|
602fc8c6e7 | ||
|
|
e4fd7a6aee | ||
|
|
12cb74bc05 | ||
|
|
0266de3532 | ||
|
|
e235ce52e5 | ||
|
|
d4c266561f | ||
|
|
bd25447a8f | ||
|
|
2c435ef751 | ||
|
|
96310ba1d0 | ||
|
|
869eada21c | ||
|
|
1d13a62088 | ||
|
|
43bb32e64b | ||
|
|
f38262c0ab | ||
|
|
6e0bfa2cee | ||
|
|
07d270a82d | ||
|
|
1b2e80d2c8 | ||
|
|
329389bb41 | ||
|
|
c8f801da83 | ||
|
|
20f0764c68 | ||
|
|
10f17a1bba | ||
|
|
c2b02ea07c | ||
|
|
81e8ebe839 | ||
|
|
d665856a7c | ||
|
|
1544cb81d5 | ||
|
|
e4abc6d256 | ||
|
|
7901cad90b | ||
|
|
ea5a84b3dd | ||
|
|
9d0422a898 | ||
|
|
4fd4792dd8 | ||
|
|
802f980c6f | ||
|
|
435be7c63d | ||
|
|
e2b57b55d6 | ||
|
|
b3f74d37e1 | ||
|
|
91b70038e6 | ||
|
|
08eca9ac27 | ||
|
|
55916f31aa | ||
|
|
b9abe9c119 | ||
|
|
cb1605bf23 | ||
|
|
dcc533ef49 | ||
|
|
cdafe47c9a | ||
|
|
365ad54f10 | ||
|
|
ded8c99ce2 | ||
|
|
b1d7da5320 | ||
|
|
467fa11a17 | ||
|
|
3346497a25 | ||
|
|
6ea0e176c9 | ||
|
|
8ea443cde1 | ||
|
|
c2d0d80b9f | ||
|
|
cbe72307a0 | ||
|
|
e57b47ec82 | ||
|
|
518bf04e1d | ||
|
|
a430e9b3d3 | ||
|
|
75ce72ee83 | ||
|
|
5d60ab35de | ||
|
|
33f9369883 | ||
|
|
7d1abf0f7c | ||
|
|
17d1061204 | ||
|
|
feb37eea2d | ||
|
|
6bde2fd20a | ||
|
|
7b25cc399d | ||
|
|
525175f04a | ||
|
|
a2aabeaad2 | ||
|
|
cdfcdcc3b7 | ||
|
|
56244ad873 | ||
|
|
e6399517ee | ||
|
|
1c3223f551 | ||
|
|
f4f2976907 | ||
|
|
76f65198bb | ||
|
|
971bcf4f41 | ||
|
|
b49074a786 | ||
|
|
eea89d3b62 | ||
|
|
3f7b73cf5e | ||
|
|
cbc547d322 | ||
|
|
c9a59a7417 | ||
|
|
f8eaa96412 |
@@ -1 +1,2 @@
|
||||
java openjdk-17.0.2
|
||||
uv latest
|
||||
|
||||
@@ -22,8 +22,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1599
|
||||
val canonicalVersionName = "7.60.2"
|
||||
val canonicalVersionCode = 1610
|
||||
val canonicalVersionName = "7.63.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -293,6 +293,7 @@ android {
|
||||
manifestPlaceholders["mapsKey"] = getMapsKey()
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Debug\"")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
|
||||
}
|
||||
|
||||
getByName("release") {
|
||||
@@ -318,7 +319,6 @@ android {
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Spinner\"")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("perf") {
|
||||
@@ -378,6 +378,7 @@ android {
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("prod") {
|
||||
@@ -649,7 +650,6 @@ dependencies {
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
|
||||
androidTestImplementation(testLibs.assertk)
|
||||
androidTestImplementation(testLibs.mockk.android)
|
||||
androidTestImplementation(testLibs.square.okhttp.mockserver)
|
||||
androidTestImplementation(testLibs.diff.utils)
|
||||
|
||||
androidTestUtil(testLibs.androidx.test.orchestrator)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -211,6 +211,11 @@ class ArchiveImportExportTests {
|
||||
runTests { it.startsWith("chat_item_view_once_") }
|
||||
}
|
||||
|
||||
// @Test
|
||||
fun chatItemPoll() {
|
||||
runTests { it.startsWith("chat_item_poll_") }
|
||||
}
|
||||
|
||||
// @Test
|
||||
fun notificationProfiles() {
|
||||
runTests { it.startsWith("notification_profile_") }
|
||||
|
||||
@@ -25,12 +25,14 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.billing.BillingProduct
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.billing.BillingPurchaseState
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -45,6 +47,7 @@ import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@Ignore
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageBackupsCheckoutActivityTest {
|
||||
|
||||
@@ -63,6 +66,7 @@ class MessageBackupsCheckoutActivityTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { AppDependencies.billingApi.getBillingPurchaseResults() } returns purchaseResults
|
||||
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
|
||||
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
|
||||
coEvery { AppDependencies.billingApi.launchBillingFlow(any()) } returns Unit
|
||||
}
|
||||
@@ -136,7 +140,7 @@ class MessageBackupsCheckoutActivityTest {
|
||||
|
||||
// Key education screen
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyEducationScreen__your_backup_key)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyEducationScreen__view_recovery_key)).performClick()
|
||||
|
||||
// Key record screen
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key)).assertIsDisplayed()
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Base64.decodeBase64OrThrow
|
||||
import org.signal.core.util.copyTo
|
||||
import org.signal.core.util.stream.NullOutputStream
|
||||
@@ -37,6 +38,7 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
|
||||
import org.whispersystems.signalservice.api.crypto.NoCipherOutputStream
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.Optional
|
||||
@@ -326,6 +328,92 @@ class AttachmentTableTest {
|
||||
assertThat(attachments).isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* There's a race condition where the following was happening:
|
||||
*
|
||||
* 1. Receive attachment A
|
||||
* 2. Download attachment A
|
||||
* 3. Enqueue copy to archive job for A (old media name)
|
||||
* 4. Receive attachment B that is identical to A
|
||||
* 5. Dedupe B with A's data file but update A to match B's "newer" remote key
|
||||
* 6. Enqueue copy to archive job for B (new media name)
|
||||
* 7. Copy to archive for A succeeds for old media name, updating A and B to FINISHED
|
||||
* 8. Copy to archive for B for new media name early aborts because B is already marked FINISHED
|
||||
*
|
||||
* THe problem is Step 7 because it's marking attachments as archived but under the old media and not the new media name.
|
||||
*
|
||||
* This tests recreates the flow but ensures Step 7 doesn't mark A and B as finished so that Step 8 will not early abort and copy
|
||||
* B over with the new media name.
|
||||
*/
|
||||
@Test
|
||||
fun givenAnDuplicateAttachmentPriorToCopyToArchive_whenICopyFirstAttachmentToArchive_thenIDoNotExpectBothAttachmentsToChangeArchiveStateToFinished() {
|
||||
val data = byteArrayOf(1, 2, 3, 4, 5)
|
||||
|
||||
val attachment1 = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
|
||||
val attachment2 = createAttachmentPointer("remote-key-2".toByteArray(), data.size)
|
||||
|
||||
// Insert Message 1
|
||||
val message1Result = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment1)).get()
|
||||
val message1Id = message1Result.messageId
|
||||
val attachment1Id = message1Result.insertedAttachments!![attachment1]!!
|
||||
// AttachmentDownloadJob#onAdded
|
||||
SignalDatabase.attachments.setTransferState(message1Id, attachment1Id, AttachmentTable.TRANSFER_PROGRESS_STARTED)
|
||||
|
||||
// Insert Message 2
|
||||
val message2Result = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 1.days, attachment = attachment2)).get()
|
||||
val message2Id = message2Result.messageId
|
||||
val attachment2Id = message2Result.insertedAttachments!![attachment2]!!
|
||||
// AttachmentDownloadJob#onAdded
|
||||
SignalDatabase.attachments.setTransferState(message2Id, attachment2Id, AttachmentTable.TRANSFER_PROGRESS_STARTED)
|
||||
|
||||
// Finalize Attachment 1 download
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(message1Id, attachment1Id, ByteArrayInputStream(data))
|
||||
// CopyAttachmentToArchiveJob#onAdded
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachment1Id, AttachmentTable.ArchiveTransferState.COPY_PENDING)
|
||||
|
||||
// Verify Attachment 1 data matches original Attachment 1 data from insert
|
||||
var dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.COPY_PENDING)
|
||||
assertThat(dbAttachment1.remoteKey).isEqualTo(Base64.encodeWithPadding("remote-key-1".toByteArray()))
|
||||
|
||||
val attachment1InitialRemoteKey = dbAttachment1.remoteKey!!
|
||||
val attachment1InitialPlaintextHash = dbAttachment1.dataHash!!
|
||||
|
||||
// Finalize Attachment 2
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterDownload(message2Id, attachment2Id, ByteArrayInputStream(data))
|
||||
|
||||
// Verify Attachment 1 data matches Attachment 2 data from insert and dedupe in finalize
|
||||
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
var dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(dbAttachment1.remoteKey).isEqualTo(dbAttachment2.remoteKey)
|
||||
assertThat(dbAttachment1.dataHash).isEqualTo(dbAttachment2.dataHash)
|
||||
|
||||
val attachment2InitialRemoteKey = dbAttachment2.remoteKey!!
|
||||
val attachment2InitialPlaintextHash = dbAttachment2.dataHash!!
|
||||
|
||||
// "Finish" Copy to Archive for Attachment 1
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachment1Id, attachment1InitialRemoteKey, attachment1InitialPlaintextHash, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
|
||||
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
|
||||
|
||||
// Verify Attachment 1 and 2 are not updated as FINISHED since Attachment 1's media name parts have changed
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
|
||||
|
||||
// "Finish" Copy to Archive for Attachment 2
|
||||
SignalDatabase.attachments.setArchiveTransferState(attachment2Id, attachment2InitialRemoteKey, attachment2InitialPlaintextHash, AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
|
||||
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
|
||||
dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
|
||||
|
||||
// Verify Attachment 1 and 2 are updated as FINISHED
|
||||
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
|
||||
}
|
||||
|
||||
private fun createIncomingMessage(
|
||||
serverTime: Duration,
|
||||
attachment: Attachment,
|
||||
@@ -343,7 +431,7 @@ class AttachmentTableTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAttachmentPointer(key: ByteArray, digest: ByteArray, size: Int): Attachment {
|
||||
private fun createAttachmentPointer(key: ByteArray, size: Int): Attachment {
|
||||
return PointerAttachment.forPointer(
|
||||
pointer = Optional.of(
|
||||
SignalServiceAttachmentPointer(
|
||||
@@ -355,7 +443,7 @@ class AttachmentTableTest {
|
||||
preview = Optional.empty(),
|
||||
width = 2,
|
||||
height = 2,
|
||||
digest = Optional.of(digest),
|
||||
digest = Optional.of(byteArrayOf()),
|
||||
incrementalDigest = Optional.empty(),
|
||||
incrementalMacChunkSize = 0,
|
||||
fileName = Optional.of("file.jpg"),
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.polls.Voter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@@ -28,7 +30,7 @@ class PollTablesTest {
|
||||
id = 1,
|
||||
question = "how do you feel about unit testing?",
|
||||
pollOptions = listOf(
|
||||
PollOption(1, "yay", listOf(1)),
|
||||
PollOption(1, "yay", listOf(Voter(1, 1))),
|
||||
PollOption(2, "ok", emptyList()),
|
||||
PollOption(3, "nay", emptyList())
|
||||
),
|
||||
@@ -79,7 +81,7 @@ class PollTablesTest {
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 1, voteCount = 2, messageId = MessageId(1))
|
||||
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 3, messageId = MessageId(1))
|
||||
|
||||
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
|
||||
assertEquals(listOf(Voter(1, 3)), SignalDatabase.polls.getPoll(1)!!.pollOptions[0].voters)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -99,7 +101,7 @@ class PollTablesTest {
|
||||
val voteCount = SignalDatabase.polls.insertVote(poll, pollOption)
|
||||
|
||||
assertEquals(1, voteCount)
|
||||
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false))
|
||||
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false, voteCount))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -109,23 +111,25 @@ class PollTablesTest {
|
||||
val pollOption = poll.pollOptions.first()
|
||||
|
||||
val voteCount = SignalDatabase.polls.removeVote(poll, pollOption)
|
||||
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1)
|
||||
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1, pollOption.id)
|
||||
|
||||
assertEquals(1, voteCount)
|
||||
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, voteCount)
|
||||
assertEquals(PollTables.VoteState.REMOVED, status)
|
||||
val votes = SignalDatabase.polls.getVotes(poll.id, false, voteCount)
|
||||
assertTrue(votes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAVote_whenISetPollOptionId_thenOptionIdIsUpdated() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
|
||||
fun givenAPendingVote_whenIRevertThatVote_thenItGoesToMostRecentResolvedState() {
|
||||
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", true, listOf("yay", "ok", "nay"), 1, 1)
|
||||
val poll = SignalDatabase.polls.getPoll(1)!!
|
||||
val option = poll.pollOptions.first()
|
||||
|
||||
SignalDatabase.polls.insertVotes(poll.id, listOf(option.id), Recipient.self().id.toLong(), 5, MessageId(1))
|
||||
SignalDatabase.polls.setPollVoteStateForGivenVote(poll.id, Recipient.self().id.toLong(), 5, 1, true)
|
||||
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, 5)
|
||||
SignalDatabase.polls.markPendingAsAdded(poll.id, Recipient.self().id.toLong(), 5, 1, option.id)
|
||||
SignalDatabase.polls.removeVote(poll, option)
|
||||
|
||||
assertEquals(PollTables.VoteState.ADDED, status)
|
||||
SignalDatabase.polls.removePendingVote(poll.id, option.id, 6, 1)
|
||||
val votes = SignalDatabase.polls.getVotes(1, true, 6)
|
||||
assertEquals(listOf(0), votes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import android.app.Application
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okio.ByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.billing.BillingApi
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.Verb
|
||||
import org.thoughtcrime.securesms.testing.runSync
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
@@ -32,17 +14,8 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
import org.whispersystems.signalservice.api.donations.DonationsApi
|
||||
import org.whispersystems.signalservice.api.keys.KeysApi
|
||||
import org.whispersystems.signalservice.api.message.MessageApi
|
||||
import org.whispersystems.signalservice.api.push.TrustStore
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import java.net.InetAddress
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Dependency provider used for instrumentation tests (aka androidTests).
|
||||
@@ -51,70 +24,12 @@ import java.util.Optional
|
||||
*/
|
||||
class InstrumentationApplicationDependencyProvider(val application: Application, private val default: ApplicationDependencyProvider) : AppDependencies.Provider by default {
|
||||
|
||||
private val serviceTrustStore: TrustStore
|
||||
private val uncensoredConfiguration: SignalServiceConfiguration
|
||||
private val serviceNetworkAccessMock: SignalServiceNetworkAccess
|
||||
private val recipientCache: LiveRecipientCache
|
||||
private var signalServiceMessageSender: SignalServiceMessageSender? = null
|
||||
private var billingApi: BillingApi = mockk()
|
||||
private var accountApi: AccountApi = mockk()
|
||||
|
||||
init {
|
||||
runSync {
|
||||
webServer = MockWebServer()
|
||||
webServer.start(InetAddress.getByAddress(byteArrayOf(0x7f, 0x0, 0x0, 0x1)), 8080)
|
||||
|
||||
baseUrl = webServer.url("").toString()
|
||||
|
||||
addMockWebRequestHandlers(
|
||||
Get("/v1/websocket/?login=") {
|
||||
MockResponse().success().withWebSocketUpgrade(mockIdentifiedWebSocket)
|
||||
},
|
||||
Get("/v1/websocket", {
|
||||
val path = it.path
|
||||
return@Get path == null || !path.contains("login")
|
||||
}) {
|
||||
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
webServer.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val handler = handlers.firstOrNull { it.requestPredicate(request) }
|
||||
return handler?.responseFactory?.invoke(request) ?: MockResponse().setResponseCode(500)
|
||||
}
|
||||
}
|
||||
|
||||
serviceTrustStore = SignalServiceTrustStore(application)
|
||||
uncensoredConfiguration = SignalServiceConfiguration(
|
||||
signalServiceUrls = arrayOf(SignalServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
signalCdnUrlMap = mapOf(
|
||||
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
|
||||
),
|
||||
signalStorageUrls = arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
signalCdsiUrls = arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
signalSvr2Urls = arrayOf(SignalSvr2Url(baseUrl, serviceTrustStore, "localhost", ConnectionSpec.CLEARTEXT)),
|
||||
networkInterceptors = emptyList(),
|
||||
dns = Optional.of(SignalServiceNetworkAccess.DNS),
|
||||
signalProxy = Optional.empty(),
|
||||
systemHttpProxy = Optional.empty(),
|
||||
zkGroupServerPublicParams = Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
|
||||
genericServerPublicParams = Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS),
|
||||
backupServerPublicParams = Base64.decode(BuildConfig.BACKUP_SERVER_PUBLIC_PARAMS),
|
||||
censored = false
|
||||
)
|
||||
|
||||
serviceNetworkAccessMock = mockk()
|
||||
|
||||
every { serviceNetworkAccessMock.isCensored() } returns false
|
||||
every { serviceNetworkAccessMock.isCensored(any()) } returns false
|
||||
every { serviceNetworkAccessMock.isCountryCodeCensoredByDefault(any()) } returns false
|
||||
every { serviceNetworkAccessMock.getConfiguration() } returns uncensoredConfiguration
|
||||
every { serviceNetworkAccessMock.getConfiguration(any()) } returns uncensoredConfiguration
|
||||
every { serviceNetworkAccessMock.uncensoredConfiguration } returns uncensoredConfiguration
|
||||
|
||||
recipientCache = LiveRecipientCache(application) { r -> r.run() }
|
||||
}
|
||||
|
||||
@@ -122,10 +37,6 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
|
||||
override fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi = accountApi
|
||||
|
||||
override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess {
|
||||
return serviceNetworkAccessMock
|
||||
}
|
||||
|
||||
override fun provideRecipientCache(): LiveRecipientCache {
|
||||
return recipientCache
|
||||
}
|
||||
@@ -150,54 +61,4 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
}
|
||||
return signalServiceMessageSender!!
|
||||
}
|
||||
|
||||
class MockWebSocket : WebSocketListener() {
|
||||
private val TAG = "MockWebSocket"
|
||||
|
||||
var webSocket: WebSocket? = null
|
||||
private set
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.i(TAG, "onOpen(${webSocket.hashCode()})")
|
||||
this.webSocket = webSocket
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "onClosing(${webSocket.hashCode()}): $code, $reason")
|
||||
this.webSocket = null
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "onClosed(${webSocket.hashCode()}): $code, $reason")
|
||||
this.webSocket = null
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.w(TAG, "onFailure(${webSocket.hashCode()})", t)
|
||||
this.webSocket = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var webServer: MockWebServer
|
||||
private set
|
||||
lateinit var baseUrl: String
|
||||
private set
|
||||
|
||||
val mockIdentifiedWebSocket = MockWebSocket()
|
||||
|
||||
private val handlers: MutableList<Verb> = mutableListOf()
|
||||
|
||||
fun addMockWebRequestHandlers(vararg verbs: Verb) {
|
||||
handlers.addAll(verbs)
|
||||
}
|
||||
|
||||
fun injectWebSocketMessage(value: ByteString) {
|
||||
mockIdentifiedWebSocket.webSocket!!.send(value)
|
||||
}
|
||||
|
||||
fun clearHandlers() {
|
||||
handlers.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.jobs
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEmpty
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -11,19 +10,13 @@ import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.json.StripeIntentStatus
|
||||
import org.signal.donations.json.StripePaymentIntent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
import org.thoughtcrime.securesms.database.DonationReceiptTable
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.thoughtcrime.securesms.util.TestStripePaths
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@@ -46,8 +39,6 @@ class InAppPaymentAuthCheckJobTest {
|
||||
|
||||
@Test
|
||||
fun givenCanceledOneTimeAuthRequiredPayment_whenICheck_thenIDoNotExpectAReceipt() {
|
||||
initializeMockGetPaymentIntent(status = StripeIntentStatus.CANCELED)
|
||||
|
||||
SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.ONE_TIME_DONATION,
|
||||
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
|
||||
@@ -67,19 +58,4 @@ class InAppPaymentAuthCheckJobTest {
|
||||
val receipts = SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)
|
||||
assertThat(receipts).isEmpty()
|
||||
}
|
||||
|
||||
private fun initializeMockGetPaymentIntent(status: StripeIntentStatus) {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get(TestStripePaths.getPaymentIntentPath(TEST_INTENT_ID, TEST_CLIENT_SECRET)) {
|
||||
MockResponse().success(
|
||||
StripePaymentIntent(
|
||||
id = TEST_INTENT_ID,
|
||||
clientSecret = TEST_CLIENT_SECRET,
|
||||
status = status,
|
||||
paymentMethod = null
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.polls.Voter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
@@ -187,7 +188,7 @@ class DataMessageProcessorTest_polls {
|
||||
assertThat(messageId!!.id).isEqualTo(1)
|
||||
val poll = SignalDatabase.polls.getPoll(messageId.id)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
assertThat(poll!!.pollOptions[0].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -207,9 +208,9 @@ class DataMessageProcessorTest_polls {
|
||||
assert(messageId != null)
|
||||
val poll = SignalDatabase.polls.getPoll(messageId!!.id)
|
||||
assert(poll != null)
|
||||
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
assertThat(poll.pollOptions[1].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
assertThat(poll.pollOptions[2].voterIds).isEqualTo(listOf(bob.id.toLong()))
|
||||
assertThat(poll!!.pollOptions[0].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
assertThat(poll.pollOptions[1].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
assertThat(poll.pollOptions[2].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -44,7 +44,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
init {
|
||||
val threadIdSlot = slot<Long>()
|
||||
mockkStatic(ThreadUpdateJob::class)
|
||||
every { ThreadUpdateJob.enqueue(capture(threadIdSlot)) } answers {
|
||||
every { ThreadUpdateJob.enqueue(capture(threadIdSlot), any()) } answers {
|
||||
SignalDatabase.threads.update(threadIdSlot.captured, false)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
|
||||
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account.getServiceIds(), decryptedGroupV2Context))
|
||||
.build()
|
||||
|
||||
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime)
|
||||
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime, false)
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null).messageId
|
||||
|
||||
@@ -16,7 +16,6 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.AliceClient
|
||||
import org.thoughtcrime.securesms.testing.BobClient
|
||||
@@ -94,13 +93,7 @@ class MessageProcessingPerformanceTest {
|
||||
val lastTimestamp = envelopes.last().timestamp ?: 0
|
||||
|
||||
// Inject the envelopes into the websocket
|
||||
Thread {
|
||||
for (envelope in envelopes) {
|
||||
Log.i(TIMING_TAG, "Retrieved envelope! ${envelope.timestamp}")
|
||||
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(envelope.toWebSocketPayload())
|
||||
}
|
||||
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(webSocketTombstone())
|
||||
}.start()
|
||||
// TODO: mock websocket messages
|
||||
|
||||
// Wait until they've all been fully decrypted + processed
|
||||
harness
|
||||
|
||||
@@ -22,20 +22,14 @@ import assertk.assertThat
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.api.util.Usernames
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -53,11 +47,6 @@ class UsernameEditFragmentTest {
|
||||
computationTestScheduler = computationScheduler
|
||||
)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
@Ignore("Flakey espresso test.")
|
||||
@Test
|
||||
fun testUsernameCreationOutsideOfRegistration() {
|
||||
@@ -82,14 +71,7 @@ class UsernameEditFragmentTest {
|
||||
val discriminator = "4578"
|
||||
val username = "$nickname${Usernames.DELIMITER}$discriminator"
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/accounts/username/reserved") {
|
||||
MockResponse().success(ReserveUsernameResponse(username))
|
||||
},
|
||||
Put("/v1/accounts/username/confirm") {
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
// TODO: mock network requests as necessary
|
||||
|
||||
val scenario = createScenario(UsernameEditMode.NORMAL)
|
||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
@@ -52,13 +53,16 @@ object FakeClientHelpers {
|
||||
}
|
||||
|
||||
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.serverGuid(UUID.randomUUID().toString())
|
||||
.destinationServiceIdBinary(destination.toByteString())
|
||||
.serverGuid(serverGuid.toString())
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.content(Base64.decode(this.content).toByteString())
|
||||
.urgent(true)
|
||||
.story(false)
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import org.whispersystems.signalservice.internal.push.AddressableMessage
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange
|
||||
@@ -43,7 +44,7 @@ object MessageContentFuzzer {
|
||||
return Envelope.Builder()
|
||||
.timestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuid(serverGuid.toString())
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ object MessageContentFuzzer {
|
||||
unidentifiedStatus(
|
||||
deliveredTo.map {
|
||||
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder().buildWith {
|
||||
destinationServiceId = Recipient.resolved(it).requireServiceId().toString()
|
||||
destinationServiceIdBinary = Recipient.resolved(it).requireServiceId().toByteString()
|
||||
unidentified = true
|
||||
}
|
||||
}
|
||||
@@ -147,7 +148,7 @@ object MessageContentFuzzer {
|
||||
SyncMessage.Builder().buildWith {
|
||||
read = timestamps.map { (senderId, timestamp) ->
|
||||
SyncMessage.Read.Builder().buildWith {
|
||||
this.senderAci = Recipient.resolved(senderId).requireAci().toString()
|
||||
this.senderAciBinary = Recipient.resolved(senderId).requireAci().toByteString()
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
@@ -167,12 +168,12 @@ object MessageContentFuzzer {
|
||||
conversation = if (conversation.isGroup) {
|
||||
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
|
||||
},
|
||||
|
||||
messages = conversationDeletes.map { (author, timestamp) ->
|
||||
AddressableMessage(
|
||||
authorServiceId = Recipient.resolved(author).requireAci().toString(),
|
||||
authorServiceIdBinary = Recipient.resolved(author).requireAci().toByteString(),
|
||||
sentTimestamp = timestamp
|
||||
)
|
||||
}
|
||||
@@ -195,19 +196,19 @@ object MessageContentFuzzer {
|
||||
conversation = if (conversation.isGroup) {
|
||||
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
|
||||
},
|
||||
|
||||
mostRecentMessages = delete.messages.map { (author, timestamp) ->
|
||||
AddressableMessage(
|
||||
authorServiceId = Recipient.resolved(author).requireAci().toString(),
|
||||
authorServiceIdBinary = Recipient.resolved(author).requireAci().toByteString(),
|
||||
sentTimestamp = timestamp
|
||||
)
|
||||
},
|
||||
|
||||
mostRecentNonExpiringMessages = delete.nonExpiringMessages.map { (author, timestamp) ->
|
||||
AddressableMessage(
|
||||
authorServiceId = Recipient.resolved(author).requireAci().toString(),
|
||||
authorServiceIdBinary = Recipient.resolved(author).requireAci().toByteString(),
|
||||
sentTimestamp = timestamp
|
||||
)
|
||||
},
|
||||
@@ -232,7 +233,7 @@ object MessageContentFuzzer {
|
||||
conversation = if (conversation.isGroup) {
|
||||
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -254,10 +255,10 @@ object MessageContentFuzzer {
|
||||
conversation = if (conversation.isGroup) {
|
||||
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
|
||||
} else {
|
||||
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
|
||||
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
|
||||
},
|
||||
targetMessage = AddressableMessage(
|
||||
authorServiceId = Recipient.resolved(message.first).requireAci().toString(),
|
||||
authorServiceIdBinary = Recipient.resolved(message.first).requireAci().toByteString(),
|
||||
sentTimestamp = message.second
|
||||
),
|
||||
clientUuid = uuid?.let { UuidUtil.toByteString(it) },
|
||||
@@ -290,7 +291,7 @@ object MessageContentFuzzer {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = quoted.envelope.timestamp
|
||||
authorAci = quoted.metadata.sourceServiceId.toString()
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
|
||||
bodyRanges(quoted.content.dataMessage?.bodyRanges ?: emptyList())
|
||||
@@ -302,7 +303,7 @@ object MessageContentFuzzer {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
|
||||
authorAci = quoted.metadata.sourceServiceId.toString()
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
}
|
||||
}
|
||||
@@ -329,7 +330,7 @@ object MessageContentFuzzer {
|
||||
reaction = DataMessage.Reaction.Builder().buildWith {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorAci = reactTo.metadata.sourceServiceId.toString()
|
||||
targetAuthorAciBinary = reactTo.metadata.sourceServiceId.toByteString()
|
||||
targetSentTimestamp = reactTo.envelope.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
typealias ResponseFactory = (request: RecordedRequest) -> MockResponse
|
||||
typealias RequestPredicate = (request: RecordedRequest) -> Boolean
|
||||
|
||||
/**
|
||||
* Represent an HTTP verb for mocking web requests.
|
||||
*/
|
||||
sealed class Verb(val requestPredicate: RequestPredicate, val responseFactory: ResponseFactory)
|
||||
|
||||
class Get(path: String, predicate: RequestPredicate, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("GET", path, predicate), responseFactory) {
|
||||
constructor(path: String, responseFactory: ResponseFactory) : this(path, { true }, responseFactory)
|
||||
}
|
||||
|
||||
class Put(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("PUT", path), responseFactory)
|
||||
|
||||
class Post(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("POST", path), responseFactory)
|
||||
|
||||
class Delete(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("DELETE", path), responseFactory)
|
||||
|
||||
fun MockResponse.success(response: Any? = null): MockResponse {
|
||||
return setResponseCode(200).apply {
|
||||
if (response != null) {
|
||||
setBody(JsonUtils.toJson(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MockResponse.failure(code: Int, response: Any? = null): MockResponse {
|
||||
return setResponseCode(code).apply {
|
||||
if (response != null) {
|
||||
setBody(JsonUtils.toJson(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MockResponse.connectionFailure(): MockResponse {
|
||||
return setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
|
||||
}
|
||||
|
||||
fun MockResponse.timeout(): MockResponse {
|
||||
return setHeadersDelay(1, TimeUnit.DAYS)
|
||||
.setBodyDelay(1, TimeUnit.DAYS)
|
||||
}
|
||||
|
||||
inline fun <reified T> RecordedRequest.parsedRequestBody(): T {
|
||||
val bodyString = String(body.readByteArray())
|
||||
return JsonUtils.fromJson(bodyString, T::class.java)
|
||||
}
|
||||
|
||||
private fun defaultRequestPredicate(verb: String, path: String, predicate: RequestPredicate = { true }): RequestPredicate = { request ->
|
||||
request.method == verb && request.path?.startsWith("/$path") == true && predicate(request)
|
||||
}
|
||||
@@ -9,20 +9,17 @@ import android.preference.PreferenceManager
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.NewAccount
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
@@ -81,8 +78,6 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
others[1].asMember()
|
||||
)
|
||||
}
|
||||
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
private fun setupSelf(): Recipient {
|
||||
@@ -95,7 +90,6 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(Put("/v2/keys") { MockResponse().success() })
|
||||
runBlocking {
|
||||
val registrationData = RegistrationData(
|
||||
code = "123123",
|
||||
@@ -148,7 +142,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(aci.toString(), 1), otherIdentity.publicKey)
|
||||
others += recipientId
|
||||
othersKeys += otherIdentity
|
||||
@@ -161,7 +155,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
return androidx.test.core.app.launchActivity(Intent(context, T::class.java).apply(initIntent))
|
||||
}
|
||||
|
||||
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyUtil.generateIdentityKeyPair().publicKey) {
|
||||
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyPair.generate().publicKey) {
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0), identityKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -96,7 +96,7 @@ object TestUsers {
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(aci.toString(), 1), otherIdentity.publicKey)
|
||||
|
||||
others += recipientId
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
|
||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="org.thoughtcrime.securesms" />
|
||||
|
||||
<activity android:name=".components.webrtc.v2.WebRtcCallActivity"
|
||||
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
|
||||
android:excludeFromRecents="true"
|
||||
@@ -692,13 +696,8 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".NewConversationActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".conversation.NewConversationActivityV2"
|
||||
android:name=".conversation.NewConversationActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
@@ -1046,7 +1045,7 @@
|
||||
<activity android:name=".MainActivity"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden"
|
||||
android:windowSoftInputMode="stateUnchanged"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false"/>
|
||||
@@ -1056,9 +1055,10 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".groups.ui.creategroup.CreateGroupActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="false"/>
|
||||
<activity
|
||||
android:name=".groups.ui.creategroup.CreateGroupActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.addtogroup.AddToGroupsActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
|
||||
@@ -490,7 +490,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
}
|
||||
|
||||
private void ensureProfileUploaded() {
|
||||
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
|
||||
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty() && SignalStore.account().isPrimaryDevice()) {
|
||||
Log.w(TAG, "User has a profile, but has not uploaded one. Uploading now.");
|
||||
AppDependencies.getJobManager().add(new ProfileUploadJob());
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
@@ -69,9 +70,9 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
if (!getIntent().hasExtra(ContactSelectionArguments.DISPLAY_MODE)) {
|
||||
int displayMode = ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
|
||||
getIntent().putExtra(ContactSelectionArguments.DISPLAY_MODE, displayMode);
|
||||
}
|
||||
|
||||
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
|
||||
|
||||
@@ -20,7 +20,6 @@ package org.thoughtcrime.securesms;
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Rect;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
@@ -66,6 +65,7 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
@@ -86,7 +86,6 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -97,7 +96,7 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import kotlin.Unit;
|
||||
|
||||
/**
|
||||
* Fragment for selecting a one or more contacts from a list.
|
||||
* Fragment for selecting one or more contacts from a list.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
@@ -110,17 +109,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
public static final String SELECTION_LIMITS = "selection_limits";
|
||||
public static final String CURRENT_SELECTION = "current_selection";
|
||||
public static final String HIDE_COUNT = "hide_count";
|
||||
public static final String CAN_SELECT_SELF = "can_select_self";
|
||||
public static final String DISPLAY_CHIPS = "display_chips";
|
||||
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
|
||||
public static final String RV_CLIP = "recycler_view_clipping";
|
||||
public static final String INCLUDE_CHAT_TYPES = "include_chat_types";
|
||||
private ContactSelectionArguments fragmentArgs;
|
||||
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView emptyText;
|
||||
@@ -157,11 +146,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
super.onAttach(context);
|
||||
|
||||
if (context instanceof NewConversationCallback) {
|
||||
newConversationCallback = (NewConversationCallback) context;
|
||||
setNewConversationCallback((NewConversationCallback) context);
|
||||
}
|
||||
|
||||
if (context instanceof FindByCallback) {
|
||||
showFindByUsernameAndPhoneOptions((FindByCallback) context);
|
||||
setFindByCallback((FindByCallback) context);
|
||||
}
|
||||
|
||||
if (context instanceof NewCallCallback) {
|
||||
@@ -169,11 +158,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof ScrollCallback) {
|
||||
scrollCallback = (ScrollCallback) getParentFragment();
|
||||
setScrollCallback((ScrollCallback) getParentFragment());
|
||||
}
|
||||
|
||||
if (context instanceof ScrollCallback) {
|
||||
scrollCallback = (ScrollCallback) context;
|
||||
setScrollCallback((ScrollCallback) context);
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnContactSelectedListener) {
|
||||
@@ -201,22 +190,34 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (context instanceof OnItemLongClickListener) {
|
||||
onItemLongClickListener = (OnItemLongClickListener) context;
|
||||
setOnItemLongClickListener((OnItemLongClickListener) context);
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnItemLongClickListener) {
|
||||
onItemLongClickListener = (OnItemLongClickListener) getParentFragment();
|
||||
setOnItemLongClickListener((OnItemLongClickListener) getParentFragment());
|
||||
}
|
||||
}
|
||||
|
||||
public void showFindByUsernameAndPhoneOptions(@Nullable FindByCallback callback) {
|
||||
public void setNewConversationCallback(@Nullable NewConversationCallback callback) {
|
||||
this.newConversationCallback = callback;
|
||||
}
|
||||
|
||||
public void setFindByCallback(@Nullable FindByCallback callback) {
|
||||
this.findByCallback = callback;
|
||||
}
|
||||
|
||||
public void setScrollCallback(@Nullable ScrollCallback callback) {
|
||||
this.scrollCallback = callback;
|
||||
}
|
||||
|
||||
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
|
||||
this.onContactSelectedListener = listener;
|
||||
}
|
||||
|
||||
public void setOnItemLongClickListener(@Nullable OnItemLongClickListener listener) {
|
||||
this.onItemLongClickListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle icicle) {
|
||||
super.onActivityCreated(icicle);
|
||||
@@ -275,28 +276,20 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
|
||||
Intent intent = requireActivity().getIntent();
|
||||
Bundle arguments = safeArguments();
|
||||
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
|
||||
|
||||
int recyclerViewPadBottom = arguments.getInt(RV_PADDING_BOTTOM, intent.getIntExtra(RV_PADDING_BOTTOM, -1));
|
||||
boolean recyclerViewClipping = arguments.getBoolean(RV_CLIP, intent.getBooleanExtra(RV_CLIP, true));
|
||||
|
||||
if (recyclerViewPadBottom != -1) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, recyclerViewPadBottom);
|
||||
if (fragmentArgs.getRecyclerPadBottom() != -1) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
|
||||
}
|
||||
|
||||
recyclerView.setClipToPadding(recyclerViewClipping);
|
||||
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
|
||||
|
||||
boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true));
|
||||
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
|
||||
swipeRefresh.setEnabled(isRefreshable);
|
||||
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
|
||||
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
|
||||
|
||||
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
||||
if (selectionLimit == null) {
|
||||
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
|
||||
}
|
||||
isMulti = selectionLimit != null;
|
||||
canSelectSelf = arguments.getBoolean(CAN_SELECT_SELF, intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti));
|
||||
selectionLimit = fragmentArgs.getSelectionLimits();
|
||||
isMulti = selectionLimit != null;
|
||||
canSelectSelf = fragmentArgs.getCanSelectSelf();
|
||||
|
||||
if (!isMulti) {
|
||||
selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
@@ -453,14 +446,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
onRefreshListener = null;
|
||||
}
|
||||
|
||||
public int getSelectedMembersSize() {
|
||||
if (contactSearchMediator == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return contactSearchMediator.getSelectedMembersSize();
|
||||
}
|
||||
|
||||
private @NonNull Bundle safeArguments() {
|
||||
return getArguments() != null ? getArguments() : new Bundle();
|
||||
}
|
||||
@@ -482,7 +467,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public int getSelectedContactsCount() {
|
||||
return getSelectedMembersSize();
|
||||
if (contactSearchMediator == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return contactSearchMediator.getSelectedContacts().size();
|
||||
}
|
||||
|
||||
public int getTotalMemberCount() {
|
||||
@@ -494,13 +483,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
|
||||
if (currentSelection == null) {
|
||||
currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
|
||||
}
|
||||
|
||||
return currentSelection == null ? Collections.emptySet()
|
||||
: Collections.unmodifiableSet(new HashSet<>(currentSelection));
|
||||
return Set.copyOf(fragmentArgs.getCurrentSelection());
|
||||
}
|
||||
|
||||
public boolean isMulti() {
|
||||
@@ -617,7 +600,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private boolean shouldDisplayRecents() {
|
||||
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
return fragmentArgs.getIncludeRecents();
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@@ -869,7 +852,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void setChipGroupVisibility(int visibility) {
|
||||
if (!safeArguments().getBoolean(DISPLAY_CHIPS, requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true))) {
|
||||
if (!fragmentArgs.getDisplayChips()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -885,7 +868,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
constraintSet.applyTo(constraintLayout);
|
||||
}
|
||||
|
||||
public void setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener onRefreshListener) {
|
||||
public void setOnRefreshListener(@Nullable SwipeRefreshLayout.OnRefreshListener onRefreshListener) {
|
||||
this.onRefreshListener = onRefreshListener;
|
||||
this.swipeRefresh.setOnRefreshListener(onRefreshListener);
|
||||
}
|
||||
@@ -896,9 +879,9 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private @NonNull ContactSearchConfiguration mapStateToConfiguration(@NonNull ContactSearchState contactSearchState) {
|
||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, requireActivity().getIntent().getIntExtra(DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ALL));
|
||||
int displayMode = fragmentArgs.getDisplayMode();
|
||||
|
||||
boolean includeRecents = safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
boolean includeRecents = fragmentArgs.getIncludeRecents();
|
||||
boolean includePushContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_PUSH);
|
||||
boolean includeSmsContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SMS);
|
||||
boolean includeActiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
|
||||
@@ -910,7 +893,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
|
||||
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
|
||||
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
|
||||
boolean includeChatTypes = safeArguments().getBoolean(INCLUDE_CHAT_TYPES);
|
||||
boolean includeChatTypes = fragmentArgs.getIncludeChatTypes();
|
||||
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
|
||||
|
||||
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
|
||||
@@ -928,12 +911,15 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
|
||||
}
|
||||
|
||||
if (newConversationCallback != null && !hasQuery) {
|
||||
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
|
||||
}
|
||||
|
||||
if (findByCallback != null && !hasQuery) {
|
||||
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
|
||||
}
|
||||
|
||||
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -30,6 +29,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
@@ -46,9 +47,11 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.DialogFragment
|
||||
@@ -60,6 +63,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -89,6 +94,7 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.NewConversationActivity
|
||||
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
|
||||
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
|
||||
@@ -101,8 +107,8 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.main.ChatNavGraphState
|
||||
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
|
||||
import org.thoughtcrime.securesms.main.InsetsViewModelUpdater
|
||||
import org.thoughtcrime.securesms.main.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeState
|
||||
@@ -158,8 +164,10 @@ import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.thoughtcrime.securesms.window.AppPaneDragHandle
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldAnimationStateFactory
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.NavigationType
|
||||
import org.thoughtcrime.securesms.window.isSplitPane
|
||||
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
|
||||
@@ -285,22 +293,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
|
||||
val callback = object : OnBackPressedCallback(toolbarViewModel.state.value.mode == MainToolbarMode.ACTION_MODE) {
|
||||
override fun handleOnBackPressed() {
|
||||
toolbarCallback.onCloseActionModeClick()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
toolbarViewModel.state.collect { state ->
|
||||
callback.isEnabled = state.mode == MainToolbarMode.ACTION_MODE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, callback)
|
||||
|
||||
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
|
||||
|
||||
setContent {
|
||||
@@ -318,14 +310,27 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
|
||||
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
|
||||
val isActionModeActive = mainToolbarState.mode == MainToolbarMode.ACTION_MODE
|
||||
val isNavigationRailVisible = mainToolbarState.mode != MainToolbarMode.SEARCH
|
||||
val isNavigationBarVisible = mainToolbarState.mode == MainToolbarMode.FULL
|
||||
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS && !isActionModeActive
|
||||
|
||||
BackHandler(enabled = isBackHandlerEnabled) {
|
||||
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
|
||||
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
|
||||
}
|
||||
|
||||
BackHandler(enabled = isActionModeActive) {
|
||||
toolbarCallback.onCloseActionModeClick()
|
||||
}
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(mainToolbarState.mode) {
|
||||
if (mainToolbarState.mode == MainToolbarMode.ACTION_MODE) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
|
||||
MainBottomChromeState(
|
||||
destination = mainToolbarState.destination,
|
||||
@@ -338,25 +343,51 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
)
|
||||
}
|
||||
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData()
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData(mainToolbarState.mode)
|
||||
|
||||
MainContainer {
|
||||
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
|
||||
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
|
||||
val navigationType = NavigationType.rememberNavigationType()
|
||||
|
||||
val detailOffset = if (mainToolbarState.mode == MainToolbarMode.SEARCH || mainToolbarState.mode == MainToolbarMode.ACTION_MODE) 0.dp else 72.dp
|
||||
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
|
||||
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
|
||||
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
|
||||
val anchors = remember(contentLayoutData, mainToolbarState) {
|
||||
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
|
||||
|
||||
val detailOffset = when {
|
||||
mainToolbarState.mode == MainToolbarMode.SEARCH -> 0.dp
|
||||
navigationType == NavigationType.BAR -> 0.dp
|
||||
else -> 80.dp
|
||||
}
|
||||
|
||||
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
|
||||
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
|
||||
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
|
||||
|
||||
listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
|
||||
}
|
||||
|
||||
val (detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor) = anchors
|
||||
|
||||
val paneExpansionState = rememberPaneExpansionState(
|
||||
anchors = listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
|
||||
key = wrappedNavigator.scaffoldValue.paneExpansionStateKey,
|
||||
anchors = anchors,
|
||||
initialAnchoredIndex = 1
|
||||
)
|
||||
|
||||
val paneAnchorIndex = rememberSaveable(paneExpansionState.currentAnchor) {
|
||||
anchors.indexOf(paneExpansionState.currentAnchor)
|
||||
}
|
||||
|
||||
LaunchedEffect(windowSizeClass) {
|
||||
val anchor = anchors[paneAnchorIndex]
|
||||
|
||||
paneExpansionState.animateTo(anchor)
|
||||
}
|
||||
|
||||
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel)
|
||||
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
@@ -365,7 +396,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
|
||||
)
|
||||
) {
|
||||
chatNavGraphBuilder()
|
||||
chatNavGraphBuilder(chatNavGraphState)
|
||||
}
|
||||
|
||||
val callsNavHostController = rememberDetailNavHostController(
|
||||
@@ -397,31 +428,81 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
|
||||
paneExpansionState.animateTo(detailOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationState.currentListLocation) {
|
||||
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
|
||||
val scope = rememberCoroutineScope()
|
||||
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
|
||||
scope.launch {
|
||||
paneExpansionState.animateTo(listOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
InsetsViewModelUpdater()
|
||||
LaunchedEffect(paneExpansionState.currentAnchor, detailOnlyAnchor, listOnlyAnchor, detailAndListAnchor) {
|
||||
val isFullScreenPane = when (paneExpansionState.currentAnchor) {
|
||||
listOnlyAnchor, detailOnlyAnchor -> {
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
mainNavigationViewModel.onPaneAnchorChanged(isFullScreenPane)
|
||||
}
|
||||
|
||||
LaunchedEffect(paneExpansionState.currentAnchor) {
|
||||
when (paneExpansionState.currentAnchor) {
|
||||
listOnlyAnchor -> {
|
||||
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
|
||||
}
|
||||
|
||||
detailOnlyAnchor -> {
|
||||
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Primary)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val paneFocusRequest by mainNavigationViewModel.paneFocusRequests.collectAsStateWithLifecycle(null)
|
||||
LaunchedEffect(paneFocusRequest) {
|
||||
if (paneFocusRequest == null) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (paneFocusRequest == ThreePaneScaffoldRole.Secondary && paneExpansionState.currentAnchor == detailOnlyAnchor) {
|
||||
paneExpansionState.animateTo(listOnlyAnchor)
|
||||
}
|
||||
|
||||
if (paneFocusRequest == ThreePaneScaffoldRole.Primary && paneExpansionState.currentAnchor == listOnlyAnchor) {
|
||||
paneExpansionState.animateTo(detailOnlyAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
val noEnterTransitionFactory = remember {
|
||||
AppScaffoldAnimationStateFactory(
|
||||
enabledStates = AppScaffoldNavigator.NavigationState.entries.filterNot {
|
||||
it == AppScaffoldNavigator.NavigationState.ENTER
|
||||
}.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
navigator = wrappedNavigator,
|
||||
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
|
||||
paneExpansionState = paneExpansionState,
|
||||
contentWindowInsets = WindowInsets(),
|
||||
bottomNavContent = {
|
||||
if (isNavigationVisible) {
|
||||
if (isNavigationBarVisible) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(contentLayoutData.navigationBarShape)
|
||||
@@ -439,7 +520,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
},
|
||||
navRailContent = {
|
||||
if (isNavigationVisible) {
|
||||
if (isNavigationRailVisible) {
|
||||
MainNavigationRail(
|
||||
state = mainNavigationState,
|
||||
mainFloatingActionButtonsCallback = mainBottomChromeCallback,
|
||||
@@ -448,7 +529,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
},
|
||||
secondaryContent = {
|
||||
val listContainerColor = if (windowSizeClass.isMedium()) {
|
||||
val listContainerColor = if (windowSizeClass.isSplitPane() && windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM) {
|
||||
SignalTheme.colors.colorSurface1
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
@@ -547,7 +628,14 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
mutableInteractionSource = mutableInteractionSource
|
||||
)
|
||||
}
|
||||
} else null
|
||||
} else {
|
||||
null
|
||||
},
|
||||
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
|
||||
noEnterTransitionFactory
|
||||
} else {
|
||||
AppScaffoldAnimationStateFactory.Default
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -621,10 +709,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
@Composable
|
||||
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
|
||||
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) {
|
||||
val backgroundColor = if (windowSizeClass.isCompact()) {
|
||||
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
SignalTheme.colors.colorSurface1
|
||||
@@ -911,7 +999,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
inner class ToolbarCallback : MainToolbarCallback {
|
||||
|
||||
override fun onNewGroupClick() {
|
||||
startActivity(CreateGroupActivity.newIntent(this@MainActivity))
|
||||
startActivity(CreateGroupActivity.createIntent(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onClearPassphraseClick() {
|
||||
@@ -997,7 +1085,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
inner class BottomChromeCallback : MainBottomChromeCallback {
|
||||
override fun onNewChatClick() {
|
||||
startActivity(Intent(this@MainActivity, NewConversationActivity::class.java))
|
||||
startActivity(NewConversationActivity.createIntent(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onNewCallClick() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
@@ -56,7 +55,7 @@ public class MainNavigator {
|
||||
}
|
||||
|
||||
public void goToGroupCreation() {
|
||||
activity.startActivity(CreateGroupActivity.newIntent(activity));
|
||||
activity.startActivity(CreateGroupActivity.createIntent(activity));
|
||||
}
|
||||
|
||||
private @NonNull FragmentManager getFragmentManager() {
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientRepository;
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity;
|
||||
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
/**
|
||||
* Activity container for starting a new conversation.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class NewConversationActivity extends ContactSelectionActivity
|
||||
implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener, ContactSelectionListFragment.FindByCallback
|
||||
{
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(NewConversationActivity.class);
|
||||
|
||||
private ContactsManagementViewModel viewModel;
|
||||
private ActivityResultLauncher<Intent> contactLauncher;
|
||||
private ActivityResultLauncher<Intent> createGroupLauncher;
|
||||
private ActivityResultLauncher<FindByMode> findByLauncher;
|
||||
|
||||
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle, boolean ready) {
|
||||
super.onCreate(bundle, ready);
|
||||
assert getSupportActionBar() != null;
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
|
||||
|
||||
disposables.bindTo(this);
|
||||
|
||||
ContactsManagementRepository repository = new ContactsManagementRepository(this);
|
||||
ContactsManagementViewModel.Factory factory = new ContactsManagementViewModel.Factory(repository);
|
||||
|
||||
contactLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), activityResult -> {
|
||||
if (activityResult.getResultCode() != RESULT_CANCELED) {
|
||||
handleManualRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
findByLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> {
|
||||
if (result != null) {
|
||||
launch(result);
|
||||
}
|
||||
});
|
||||
|
||||
createGroupLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
|
||||
if (result.getResultCode() == RESULT_OK) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
|
||||
viewModel = new ViewModelProvider(this, factory).get(ContactsManagementViewModel.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (recipientId.isPresent()) {
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
|
||||
|
||||
if (SignalStore.account().isRegistered()) {
|
||||
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
|
||||
|
||||
AlertDialog progress = SimpleProgressDialog.show(this);
|
||||
|
||||
SimpleTask.run(getLifecycle(), () -> RecipientRepository.lookupNewE164(number), result -> {
|
||||
progress.dismiss();
|
||||
|
||||
if (result instanceof RecipientRepository.LookupResult.Success) {
|
||||
Recipient resolved = Recipient.resolved(((RecipientRepository.LookupResult.Success) result).getRecipientId());
|
||||
if (resolved.isRegistered() && resolved.getHasServiceId()) {
|
||||
launch(resolved);
|
||||
}
|
||||
} else if (result instanceof RecipientRepository.LookupResult.NotFound || result instanceof RecipientRepository.LookupResult.InvalidEntry) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, number))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
} else {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
private void launch(Recipient recipient) {
|
||||
launch(recipient.getId());
|
||||
}
|
||||
|
||||
|
||||
private void launch(RecipientId recipientId) {
|
||||
Disposable disposable = ConversationIntents.createBuilder(this, recipientId, -1L)
|
||||
.map(builder -> builder
|
||||
.withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT))
|
||||
.withDataUri(getIntent().getData())
|
||||
.withDataType(getIntent().getType())
|
||||
.build())
|
||||
.subscribe(intent -> {
|
||||
startActivity(intent);
|
||||
finish();
|
||||
});
|
||||
|
||||
disposables.add(disposable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
int itemId = item.getItemId();
|
||||
|
||||
if (itemId == android.R.id.home) {
|
||||
super.onBackPressed();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_refresh) {
|
||||
handleManualRefresh();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_new_group) {
|
||||
handleCreateGroup();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_invite) {
|
||||
handleInvite();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleManualRefresh() {
|
||||
if (!contactsFragment.isRefreshing()) {
|
||||
contactsFragment.setRefreshing(true);
|
||||
onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCreateGroup() {
|
||||
createGroupLauncher.launch(CreateGroupActivity.newIntent(this));
|
||||
}
|
||||
|
||||
private void handleInvite() {
|
||||
startActivity(AppSettingsActivity.invite(this));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
menu.clear();
|
||||
getMenuInflater().inflate(R.menu.new_conversation_activity, menu);
|
||||
|
||||
super.onCreateOptionsMenu(menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInvite() {
|
||||
handleInvite();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroup(boolean forceV1) {
|
||||
handleCreateGroup();
|
||||
// finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsername() {
|
||||
findByLauncher.launch(FindByMode.USERNAME);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumber() {
|
||||
findByLauncher.launch(FindByMode.PHONE_NUMBER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
|
||||
RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
|
||||
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
|
||||
if (actions.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
|
||||
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
|
||||
.offsetX((int) DimensionUnit.DP.toPixels(12))
|
||||
.offsetY((int) DimensionUnit.DP.toPixels(12))
|
||||
.onDismiss(() -> recyclerView.suppressLayout(false))
|
||||
.show(actions);
|
||||
|
||||
recyclerView.suppressLayout(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private @NonNull List<ActionItem> generateContextualActionsForRecipient(@NonNull RecipientId recipientId) {
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
|
||||
return Stream.of(
|
||||
createMessageActionItem(recipient),
|
||||
createAudioCallActionItem(recipient),
|
||||
createVideoCallActionItem(recipient),
|
||||
createRemoveActionItem(recipient),
|
||||
createBlockActionItem(recipient)
|
||||
).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private @NonNull ActionItem createMessageActionItem(@NonNull Recipient recipient) {
|
||||
return new ActionItem(
|
||||
R.drawable.ic_chat_message_24,
|
||||
getString(R.string.NewConversationActivity__message),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> {
|
||||
Disposable disposable = ConversationIntents.createBuilder(this, recipient.getId(), -1L)
|
||||
.subscribe(builder -> startActivity(builder.build()));
|
||||
|
||||
disposables.add(disposable);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createAudioCallActionItem(@NonNull Recipient recipient) {
|
||||
if (recipient.isSelf() || recipient.isGroup()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recipient.isRegistered()) {
|
||||
return new ActionItem(
|
||||
R.drawable.ic_phone_right_24,
|
||||
getString(R.string.NewConversationActivity__audio_call),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> CommunicationActions.startVoiceCall(this, recipient, () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createVideoCallActionItem(@NonNull Recipient recipient) {
|
||||
if (recipient.isSelf() || recipient.isMmsGroup() || !recipient.isRegistered()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ActionItem(
|
||||
R.drawable.ic_video_call_24,
|
||||
getString(R.string.NewConversationActivity__video_call),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> CommunicationActions.startVideoCall(this, recipient, () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
|
||||
if (recipient.isSelf() || recipient.isGroup()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ActionItem(
|
||||
R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
|
||||
getString(R.string.NewConversationActivity__remove),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> displayRemovalDialog(recipient)
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
private @Nullable ActionItem createBlockActionItem(@NonNull Recipient recipient) {
|
||||
if (recipient.isSelf()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ActionItem(
|
||||
R.drawable.ic_block_tinted_24,
|
||||
getString(R.string.NewConversationActivity__block),
|
||||
R.color.signal_colorError,
|
||||
() -> BlockUnblockDialog.showBlockFor(this,
|
||||
this.getLifecycle(),
|
||||
recipient,
|
||||
() -> {
|
||||
disposables.add(viewModel.blockContact(recipient).subscribe(() -> {
|
||||
handleManualRefresh();
|
||||
displaySnackbar(R.string.NewConversationActivity__s_has_been_blocked, recipient.getDisplayName(this));
|
||||
contactsFragment.reset();
|
||||
}, (throwable) -> {
|
||||
displaySnackbar(R.string.NewConversationActivity__block_failed);
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void displayIsInSystemContactsDialog(@NonNull Recipient recipient) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.NewConversationActivity__unable_to_remove_s, recipient.getShortDisplayName(this)))
|
||||
.setMessage(R.string.NewConversationActivity__this_person_is_saved_to_your)
|
||||
.setPositiveButton(R.string.NewConversationActivity__view_contact,
|
||||
(dialog, which) -> contactLauncher.launch(new Intent(Intent.ACTION_VIEW, recipient.getContactUri()))
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void displayRemovalDialog(@NonNull Recipient recipient) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.NewConversationActivity__remove_s, recipient.getShortDisplayName(this)))
|
||||
.setMessage(R.string.NewConversationActivity__you_wont_see_this_person)
|
||||
.setPositiveButton(R.string.NewConversationActivity__remove,
|
||||
(dialog, which) -> {
|
||||
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
|
||||
handleManualRefresh();
|
||||
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed, recipient.getDisplayName(this));
|
||||
contactsFragment.reset();
|
||||
}));
|
||||
}
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void displaySnackbar(@StringRes int message, Object... formatArgs) {
|
||||
Snackbar.make(findViewById(android.R.id.content), getString(message, formatArgs), Snackbar.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return !SignalStore.registration().isRegistrationComplete() &&
|
||||
!SignalStore.svr().hasPin() &&
|
||||
!SignalStore.svr().lastPinCreateFailed() &&
|
||||
!SignalStore.svr().hasOptedOut();
|
||||
!SignalStore.svr().hasOptedOut() &&
|
||||
SignalStore.account().isPrimaryDevice();
|
||||
}
|
||||
|
||||
private boolean userMustSetProfileName() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.conversation.NewConversationActivity;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.Rfc5724Uri;
|
||||
@@ -41,8 +42,7 @@ public class SystemContactsEntrypointActivity extends Activity {
|
||||
final Intent nextIntent;
|
||||
|
||||
if (TextUtils.isEmpty(destination.destination)) {
|
||||
nextIntent = new Intent(this, NewConversationActivity.class);
|
||||
nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody());
|
||||
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
|
||||
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
|
||||
} else {
|
||||
Recipient recipient = Recipient.external(destination.getDestination());
|
||||
@@ -54,8 +54,7 @@ public class SystemContactsEntrypointActivity extends Activity {
|
||||
.withDraftText(destination.getBody())
|
||||
.build();
|
||||
} else {
|
||||
nextIntent = new Intent(this, NewConversationActivity.class);
|
||||
nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody());
|
||||
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
|
||||
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.rememberRecipientField
|
||||
|
||||
@Composable
|
||||
@@ -26,6 +27,21 @@ fun AvatarImage(
|
||||
modifier: Modifier = Modifier,
|
||||
useProfile: Boolean = true,
|
||||
contentDescription: String? = null
|
||||
) {
|
||||
AvatarImage(
|
||||
recipientId = recipient.id,
|
||||
modifier = modifier,
|
||||
useProfile = useProfile,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AvatarImage(
|
||||
recipientId: RecipientId,
|
||||
modifier: Modifier = Modifier,
|
||||
useProfile: Boolean = true,
|
||||
contentDescription: String? = null
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Spacer(
|
||||
@@ -34,7 +50,7 @@ fun AvatarImage(
|
||||
)
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val avatarImageState by rememberRecipientField(recipient) {
|
||||
val avatarImageState by rememberRecipientField(recipientId) {
|
||||
AvatarImageState(
|
||||
getDisplayName(context),
|
||||
this,
|
||||
|
||||
@@ -119,10 +119,26 @@ object ExportSkips {
|
||||
return log(sentTimestamp, "Failed to parse thread merge event.")
|
||||
}
|
||||
|
||||
fun pollTerminateIsEmpty(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Poll terminate update was empty.")
|
||||
}
|
||||
|
||||
fun invalidPollQuestion(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Poll question was invalid.")
|
||||
}
|
||||
|
||||
fun invalidPollOption(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Poll option was invalid.")
|
||||
}
|
||||
|
||||
fun individualChatUpdateInWrongTypeOfChat(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "A chat update that only makes sense for individual chats was found in a different kind of chat.")
|
||||
}
|
||||
|
||||
fun callWithMissingRecipient(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "A call had a ringer with no matching exported Recipient.")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[SKIP][$sentTimestamp] $message"
|
||||
}
|
||||
@@ -199,6 +215,10 @@ object ExportOddities {
|
||||
* These represent situations where we will skip importing a data frame due to the data being invalid.
|
||||
*/
|
||||
object ImportSkips {
|
||||
fun recipientWithoutId(): String {
|
||||
return log(0, " No aci, pni, or e164 available for recipient")
|
||||
}
|
||||
|
||||
fun fromRecipientNotFound(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Failed to find the fromRecipient for the message.")
|
||||
}
|
||||
@@ -223,6 +243,14 @@ object ImportSkips {
|
||||
return log(0, "Failed to parse notificationProfileId for the provided notification profile.")
|
||||
}
|
||||
|
||||
fun failedToCreateChat(): String {
|
||||
return log(0, "Failed to create a Chat. Likely a duplicate recipient was found. Keeping pre-existing data and skipping data in this frame.")
|
||||
}
|
||||
|
||||
fun missingChatRecipient(chatId: Long): String {
|
||||
return log(0, "Missing recipient for chat $chatId")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[SKIP][$sentTimestamp] $message"
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatFolderProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatFolderArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.NotificationProfileProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.NotificationProfileArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.RecipientArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.StickerArchiveProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
|
||||
@@ -564,7 +564,7 @@ object BackupRepository {
|
||||
return false
|
||||
}
|
||||
|
||||
return !SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
|
||||
return (!SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.hasValidationError) && SignalStore.backup.hasBackupFailure && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,6 +572,11 @@ object BackupRepository {
|
||||
*/
|
||||
@JvmStatic
|
||||
fun shouldDisplayCouldNotCompleteBackupSheet(): Boolean {
|
||||
// Temporarily disabling. May re-enable in the future.
|
||||
if (true) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (shouldNotDisplayBackupFailedMessaging()) {
|
||||
return false
|
||||
}
|
||||
@@ -1035,7 +1040,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
progressEmitter?.onNotificationProfile()
|
||||
NotificationProfileProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
NotificationProfileArchiveProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("notification-profile")
|
||||
@@ -1047,7 +1052,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
progressEmitter?.onChatFolder()
|
||||
ChatFolderProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
ChatFolderArchiveProcessor.export(dbSnapshot, exportState) { frame ->
|
||||
writer.write(frame)
|
||||
extraFrameOperation?.invoke(frame)
|
||||
eventTimer.emit("chat-folder")
|
||||
@@ -1124,20 +1129,25 @@ object BackupRepository {
|
||||
forwardSecrecyToken: BackupForwardSecrecyToken,
|
||||
cancellationSignal: () -> Boolean = { false }
|
||||
): ImportResult {
|
||||
val frameReader = if (backupKey == null) {
|
||||
PlainTextBackupReader(inputStreamFactory(), length)
|
||||
} else {
|
||||
EncryptedBackupReader.createForSignalBackup(
|
||||
key = backupKey,
|
||||
aci = selfData.aci,
|
||||
forwardSecrecyToken = forwardSecrecyToken,
|
||||
length = length,
|
||||
dataStream = inputStreamFactory
|
||||
)
|
||||
}
|
||||
try {
|
||||
val frameReader = if (backupKey == null) {
|
||||
PlainTextBackupReader(inputStreamFactory(), length)
|
||||
} else {
|
||||
EncryptedBackupReader.createForSignalBackup(
|
||||
key = backupKey,
|
||||
aci = selfData.aci,
|
||||
forwardSecrecyToken = forwardSecrecyToken,
|
||||
length = length,
|
||||
dataStream = inputStreamFactory
|
||||
)
|
||||
}
|
||||
|
||||
return frameReader.use { reader ->
|
||||
import(reader, selfData, cancellationSignal)
|
||||
return frameReader.use { reader ->
|
||||
import(reader, selfData, cancellationSignal)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to restore signal backup", e)
|
||||
return ImportResult.Failure
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1281,6 +1291,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
RecipientId.clearCache()
|
||||
SignalDatabase.remappedRecords.clearCache()
|
||||
AppDependencies.recipientCache.clear()
|
||||
AppDependencies.recipientCache.clearSelf()
|
||||
SignalDatabase.threads.clearCache()
|
||||
@@ -1338,13 +1349,13 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
frame.notificationProfile != null -> {
|
||||
NotificationProfileProcessor.import(frame.notificationProfile, importState)
|
||||
NotificationProfileArchiveProcessor.import(frame.notificationProfile, importState)
|
||||
eventTimer.emit("notification-profile")
|
||||
frameCount++
|
||||
}
|
||||
|
||||
frame.chatFolder != null -> {
|
||||
ChatFolderProcessor.import(frame.chatFolder, importState)
|
||||
ChatFolderArchiveProcessor.import(frame.chatFolder, importState)
|
||||
eventTimer.emit("chat-folder")
|
||||
frameCount++
|
||||
}
|
||||
@@ -1381,6 +1392,9 @@ object BackupRepository {
|
||||
|
||||
stopwatch.split("frames")
|
||||
|
||||
Log.d(TAG, "[import] Remove duplicate messages...")
|
||||
SignalDatabase.messages.removeDuplicatesPostBackupRestore()
|
||||
|
||||
Log.d(TAG, "[import] Rebuilding FTS index...")
|
||||
SignalDatabase.messageSearch.rebuildIndex()
|
||||
|
||||
@@ -1420,6 +1434,7 @@ object BackupRepository {
|
||||
SignalDatabase.rawDatabase.forceForeignKeyConstraintsEnabled(true)
|
||||
}
|
||||
|
||||
SignalDatabase.remappedRecords.clearCache()
|
||||
AppDependencies.recipientCache.clear()
|
||||
AppDependencies.recipientCache.warmUp()
|
||||
SignalDatabase.threads.clearCache()
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.ParallelEventTimer
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.emptyIfNull
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
@@ -50,6 +51,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Poll
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.PollTerminateUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
|
||||
@@ -93,9 +96,11 @@ import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.mb
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.io.Closeable
|
||||
@@ -114,6 +119,8 @@ private val TAG = Log.tag(ChatItemArchiveExporter::class.java)
|
||||
private val MAX_INLINED_BODY_SIZE = 128.kibiBytes.bytes.toInt()
|
||||
private val MAX_INLINED_BODY_SIZE_WITH_LONG_ATTACHMENT_POINTER = 2.kibiBytes.bytes.toInt()
|
||||
private val MAX_INLINED_QUOTE_BODY_SIZE = 2.kibiBytes.bytes.toInt()
|
||||
private const val MAX_POLL_CHARACTER_LENGTH = 100
|
||||
private const val MAX_POLL_OPTIONS = 10
|
||||
|
||||
/**
|
||||
* An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions,
|
||||
@@ -134,6 +141,7 @@ class ChatItemArchiveExporter(
|
||||
|
||||
companion object {
|
||||
val EXPIRATION_CUTOFF = 1.days
|
||||
private val MAX_BUFFER_MEMORY_SIZE = 15.mb
|
||||
}
|
||||
|
||||
/** Timer for more macro-level events, like fetching extra data vs transforming the data. */
|
||||
@@ -371,6 +379,30 @@ class ChatItemArchiveExporter(
|
||||
transformTimer.emit("story")
|
||||
}
|
||||
|
||||
MessageTypes.isPollTerminate(record.type) -> {
|
||||
val pollTerminateUpdate = record.toRemotePollTerminateUpdate()
|
||||
if (pollTerminateUpdate == null) {
|
||||
Log.w(TAG, ExportSkips.pollTerminateIsEmpty(record.dateSent))
|
||||
continue
|
||||
}
|
||||
builder.updateMessage = ChatUpdateMessage(pollTerminate = pollTerminateUpdate)
|
||||
transformTimer.emit("poll-terminate")
|
||||
}
|
||||
|
||||
extraData.pollsById[record.id] != null -> {
|
||||
val poll = extraData.pollsById[record.id]!!
|
||||
if (poll.question.isEmpty() || poll.question.length > MAX_POLL_CHARACTER_LENGTH) {
|
||||
Log.w(TAG, ExportSkips.invalidPollQuestion(record.dateSent))
|
||||
continue
|
||||
}
|
||||
if (poll.pollOptions.isEmpty() || poll.pollOptions.size > MAX_POLL_OPTIONS || poll.pollOptions.any { it.text.isEmpty() || it.text.length > MAX_POLL_CHARACTER_LENGTH }) {
|
||||
Log.w(TAG, ExportSkips.invalidPollOption(record.dateSent))
|
||||
continue
|
||||
}
|
||||
builder.poll = poll.toRemotePollMessage(reactionRecords = extraData.reactionsById[record.id])
|
||||
transformTimer.emit("poll")
|
||||
}
|
||||
|
||||
else -> {
|
||||
val attachments = extraData.attachmentsById[record.id]
|
||||
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
|
||||
@@ -434,12 +466,18 @@ class ChatItemArchiveExporter(
|
||||
private fun readNextMessageRecordBatch(pastIds: Set<Long>): LinkedHashMap<Long, BackupMessageRecord> {
|
||||
return cursorGenerator(lastSeenReceivedTime, batchSize).use { cursor ->
|
||||
val records: LinkedHashMap<Long, BackupMessageRecord> = LinkedHashMap(batchSize)
|
||||
while (cursor.moveToNext()) {
|
||||
var estimatedRecordsMemorySize = 0
|
||||
while (cursor.moveToNext() && estimatedRecordsMemorySize < MAX_BUFFER_MEMORY_SIZE) {
|
||||
cursor.toBackupMessageRecord(pastIds, backupStartTime)?.let { record ->
|
||||
records[record.id] = record
|
||||
lastSeenReceivedTime = record.dateReceived
|
||||
estimatedRecordsMemorySize += record.estimatedSizeInBytes
|
||||
}
|
||||
}
|
||||
|
||||
if (estimatedRecordsMemorySize > MAX_BUFFER_MEMORY_SIZE) {
|
||||
Log.d(TAG, "[readNextMessageRecordBatch] recordsSize = ${records.size} recordsMemSize: ${estimatedRecordsMemorySize.bytes.toUnitString(spaced = false)}")
|
||||
}
|
||||
records
|
||||
}
|
||||
}
|
||||
@@ -471,16 +509,24 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
}
|
||||
|
||||
val pollsFuture = executor.submitTyped {
|
||||
extraDataTimer.timeEvent("polls") {
|
||||
db.pollTable.getPollsForMessages(messageIds = messageIds, includePending = false)
|
||||
}
|
||||
}
|
||||
|
||||
val mentionsResult = mentionsFuture.get()
|
||||
val reactionsResult = reactionsFuture.get()
|
||||
val attachmentsResult = attachmentsFuture.get()
|
||||
val groupReceiptsResult = groupReceiptsFuture.get()
|
||||
val pollsResult = pollsFuture.get()
|
||||
|
||||
return ExtraMessageData(
|
||||
mentionsById = mentionsResult,
|
||||
reactionsById = reactionsResult,
|
||||
attachmentsById = attachmentsResult,
|
||||
groupReceiptsById = groupReceiptsResult
|
||||
groupReceiptsById = groupReceiptsResult,
|
||||
pollsById = pollsResult
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -720,7 +766,7 @@ private fun CallTable.Call.toRemoteCallUpdate(exportState: ExportState, messageR
|
||||
CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING
|
||||
CallTable.Event.DELETE -> return null
|
||||
},
|
||||
ringerRecipientId = this.ringerRecipient?.toLong(),
|
||||
ringerRecipientId = this.ringerRecipient?.toLong()?.takeIf { exportState.recipientIdToAci[it] != null },
|
||||
startedCallRecipientId = groupCallUpdateDetails.startedCallUuid.takeIf { it.isNotEmpty() }?.let { exportState.aciToRecipientId[it] },
|
||||
startedCallTimestamp = this.timestamp.clampToValidBackupRange(),
|
||||
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp.clampToValidBackupRange().takeIf { it > 0 },
|
||||
@@ -783,6 +829,14 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemotePollTerminateUpdate(): PollTerminateUpdate? {
|
||||
val pollTerminate = this.messageExtras?.pollTerminate ?: return null
|
||||
return PollTerminateUpdate(
|
||||
targetSentTimestamp = pollTerminate.targetTimestamp,
|
||||
question = pollTerminate.question
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemoteSharedContact(attachments: List<DatabaseAttachment>?): Contact? {
|
||||
if (this.sharedContacts.isNullOrEmpty()) {
|
||||
return null
|
||||
@@ -1082,6 +1136,7 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
|
||||
}
|
||||
}
|
||||
QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFT_BADGE
|
||||
QuoteModel.Type.POLL -> Quote.Type.POLL
|
||||
}
|
||||
|
||||
val bodyRanges = this.quoteBodyRanges?.toRemoteBodyRanges(dateSent) ?: emptyList()
|
||||
@@ -1131,6 +1186,26 @@ private fun BackupMessageRecord.toRemoteGiftBadgeUpdate(): BackupGiftBadge? {
|
||||
)
|
||||
}
|
||||
|
||||
private fun PollRecord.toRemotePollMessage(reactionRecords: List<ReactionRecord>?): Poll {
|
||||
return Poll(
|
||||
question = this.question,
|
||||
allowMultiple = this.allowMultipleVotes,
|
||||
hasEnded = this.hasEnded,
|
||||
options = this.pollOptions.map { option ->
|
||||
Poll.PollOption(
|
||||
option = option.text,
|
||||
votes = option.voters.map { voter ->
|
||||
Poll.PollOption.PollVote(
|
||||
voterId = voter.id,
|
||||
voteCount = voter.voteCount
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
reactions = reactionRecords?.toRemote() ?: emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, reactions: List<ReactionRecord>?): StickerMessage? {
|
||||
val stickerLocator = this.stickerLocator!!
|
||||
|
||||
@@ -1491,7 +1566,8 @@ private fun Long.isDirectionlessType(): Boolean {
|
||||
MessageTypes.isGroupCall(this) ||
|
||||
MessageTypes.isGroupUpdate(this) ||
|
||||
MessageTypes.isGroupV1MigrationEvent(this) ||
|
||||
MessageTypes.isGroupQuit(this)
|
||||
MessageTypes.isGroupQuit(this) ||
|
||||
MessageTypes.isPollTerminate(this)
|
||||
}
|
||||
|
||||
private fun Long.isIdentityVerifyType(): Boolean {
|
||||
@@ -1506,7 +1582,7 @@ private fun String.e164ToLong(): Long? {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
return fixed.toLongOrNull()?.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
private fun <T> ExecutorService.submitTyped(callable: Callable<T>): Future<T> {
|
||||
@@ -1522,7 +1598,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState): ChatItem? {
|
||||
this.paymentNotification == null &&
|
||||
this.giftBadge == null &&
|
||||
this.viewOnceMessage == null &&
|
||||
this.directStoryReplyMessage == null
|
||||
this.directStoryReplyMessage == null &&
|
||||
this.poll == null
|
||||
) {
|
||||
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
|
||||
return null
|
||||
@@ -1611,6 +1688,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
|
||||
|
||||
val expiresIn = this.requireLong(MessageTable.EXPIRES_IN)
|
||||
val expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED)
|
||||
val messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS)
|
||||
|
||||
return BackupMessageRecord(
|
||||
id = id,
|
||||
@@ -1645,9 +1723,10 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
|
||||
networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(),
|
||||
identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(),
|
||||
baseType = this.requireLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK,
|
||||
messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS).parseMessageExtras(),
|
||||
messageExtras = messageExtras.parseMessageExtras(),
|
||||
viewOnce = this.requireBoolean(MessageTable.VIEW_ONCE),
|
||||
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID)
|
||||
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
|
||||
messageExtrasSize = messageExtras?.size ?: 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1686,14 +1765,24 @@ private class BackupMessageRecord(
|
||||
val identityMismatchRecipientIds: Set<Long>,
|
||||
val baseType: Long,
|
||||
val messageExtras: MessageExtras?,
|
||||
val viewOnce: Boolean
|
||||
)
|
||||
val viewOnce: Boolean,
|
||||
private val messageExtrasSize: Int
|
||||
) {
|
||||
val estimatedSizeInBytes: Int = (body?.length ?: 0) +
|
||||
(linkPreview?.length ?: 0) +
|
||||
(sharedContacts?.length ?: 0) +
|
||||
(quoteBody?.length ?: 0) +
|
||||
(quoteBodyRanges?.size ?: 0) +
|
||||
messageExtrasSize +
|
||||
((17 + networkFailureRecipientIds.size + identityMismatchRecipientIds.size) * 8)
|
||||
}
|
||||
|
||||
private data class ExtraMessageData(
|
||||
val mentionsById: Map<Long, List<Mention>>,
|
||||
val reactionsById: Map<Long, List<ReactionRecord>>,
|
||||
val attachmentsById: Map<Long, List<DatabaseAttachment>>,
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>,
|
||||
val pollsById: Map<Long, PollRecord>
|
||||
)
|
||||
|
||||
private enum class Direction {
|
||||
|
||||
@@ -28,7 +28,8 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
* Handles the importing of [Chat] models into the local database.
|
||||
*/
|
||||
object ChatArchiveImporter {
|
||||
fun import(chat: Chat, recipientId: RecipientId, importState: ImportState): Long {
|
||||
|
||||
fun import(chat: Chat, recipientId: RecipientId, importState: ImportState): Long? {
|
||||
val chatColor = chat.style?.toLocal(importState)
|
||||
|
||||
val wallpaperAttachmentId: AttachmentId? = chat.style?.wallpaperPhoto?.let { filePointer ->
|
||||
@@ -49,6 +50,11 @@ object ChatArchiveImporter {
|
||||
ThreadTable.ACTIVE to 1
|
||||
)
|
||||
.run()
|
||||
.takeIf { it > 0L }
|
||||
|
||||
if (threadId == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
SignalDatabase.writableDatabase
|
||||
.update(
|
||||
|
||||
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescrip
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
@@ -72,6 +73,7 @@ import org.thoughtcrime.securesms.payments.Direction
|
||||
import org.thoughtcrime.securesms.payments.FailureReason
|
||||
import org.thoughtcrime.securesms.payments.State
|
||||
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData
|
||||
import org.thoughtcrime.securesms.polls.Voter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -82,6 +84,7 @@ import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.math.BigInteger
|
||||
import java.sql.SQLException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
|
||||
@@ -224,12 +227,17 @@ class ChatItemArchiveImporter(
|
||||
}
|
||||
|
||||
var messageInsertIndex = 0
|
||||
SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages.map { it.contentValues }).forEach { query ->
|
||||
db.rawQuery("${query.where} RETURNING ${MessageTable.ID}", query.whereArgs).forEach { cursor ->
|
||||
val finalMessageId = cursor.requireLong(MessageTable.ID)
|
||||
val relatedInsert = buffer.messages[messageInsertIndex++]
|
||||
relatedInsert.followUp?.invoke(finalMessageId)
|
||||
try {
|
||||
SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages.map { it.contentValues }, onConflict = "IGNORE").forEach { query ->
|
||||
db.rawQuery("${query.where} RETURNING ${MessageTable.ID}", query.whereArgs).forEach { cursor ->
|
||||
val finalMessageId = cursor.requireLong(MessageTable.ID)
|
||||
val relatedInsert = buffer.messages[messageInsertIndex++]
|
||||
relatedInsert.followUp?.invoke(finalMessageId)
|
||||
}
|
||||
}
|
||||
} catch (e: SQLException) {
|
||||
Log.w(TAG, "Failed to bulk-insert message! Trying one at at time.", e)
|
||||
performIndividualMessageInserts(buffer.messages)
|
||||
}
|
||||
|
||||
SqlUtil.buildBulkInsert(ReactionTable.TABLE_NAME, REACTION_COLUMNS, buffer.reactions).forEach {
|
||||
@@ -247,6 +255,18 @@ class ChatItemArchiveImporter(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun performIndividualMessageInserts(messageInserts: List<MessageInsert>) {
|
||||
for (message in messageInserts) {
|
||||
val values = message.contentValues
|
||||
try {
|
||||
db.insert(MessageTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
message.followUp?.invoke(messageId - 1)
|
||||
} catch (e: SQLException) {
|
||||
Log.w(TAG, "Failed to insert message with timestamp ${message.contentValues.get(MessageTable.DATE_SENT)}. Must skip.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatItem.toMessageInsert(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): MessageInsert {
|
||||
val contentValues = this.toMessageContentValues(fromRecipientId, chatRecipientId, threadId)
|
||||
|
||||
@@ -304,6 +324,21 @@ class ChatItemArchiveImporter(
|
||||
)
|
||||
db.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
}
|
||||
} else if (this.updateMessage.pollTerminate != null) {
|
||||
followUps += { endPollMessageId ->
|
||||
val pollMessageId = SignalDatabase.messages.getMessageFor(updateMessage.pollTerminate.targetSentTimestamp, fromRecipientId)?.id ?: -1
|
||||
val pollId = SignalDatabase.polls.getPollId(pollMessageId)
|
||||
|
||||
val messageExtras = MessageExtras(pollTerminate = PollTerminate(question = updateMessage.pollTerminate.question, messageId = pollMessageId, targetTimestamp = updateMessage.pollTerminate.targetSentTimestamp))
|
||||
db.update(MessageTable.TABLE_NAME)
|
||||
.values(MessageTable.MESSAGE_EXTRAS to messageExtras.encode())
|
||||
.where("${MessageTable.ID} = ?", endPollMessageId)
|
||||
.run()
|
||||
|
||||
if (pollId != null) {
|
||||
SignalDatabase.polls.endPoll(pollId = pollId, endingMessageId = endPollMessageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +494,35 @@ class ChatItemArchiveImporter(
|
||||
}
|
||||
}
|
||||
|
||||
if (this.poll != null) {
|
||||
contentValues.put(MessageTable.BODY, poll.question)
|
||||
contentValues.put(MessageTable.VOTES_LAST_SEEN, System.currentTimeMillis())
|
||||
|
||||
followUps += { messageRowId ->
|
||||
val pollId = SignalDatabase.polls.insertPoll(
|
||||
question = poll.question,
|
||||
allowMultipleVotes = poll.allowMultiple,
|
||||
options = poll.options.map { it.option },
|
||||
authorId = fromRecipientId.toLong(),
|
||||
messageId = messageRowId
|
||||
)
|
||||
|
||||
val localOptionIds = SignalDatabase.polls.getPollOptionIds(pollId)
|
||||
poll.options.forEachIndexed { index, option ->
|
||||
val localVoterIds = option.votes.map { importState.remoteToLocalRecipientId[it.voterId]?.toLong() }
|
||||
val voteCounts = option.votes.map { it.voteCount }
|
||||
val localVoters = localVoterIds.mapIndexedNotNull { index, id -> id?.let { Voter(id = id, voteCount = voteCounts[index]) } }
|
||||
SignalDatabase.polls.addPollVotes(pollId = pollId, optionId = localOptionIds[index], voters = localVoters)
|
||||
}
|
||||
|
||||
if (poll.hasEnded) {
|
||||
// At this point, we don't know what message ended the poll. Instead, we set it to -1 to indicate that it
|
||||
// is ended and will update endingMessageId when we process the poll terminate message (if it exists).
|
||||
SignalDatabase.polls.endPoll(pollId = pollId, endingMessageId = -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val followUp: ((Long) -> Unit)? = if (followUps.isNotEmpty()) {
|
||||
{ messageId ->
|
||||
followUps.forEach { it(messageId) }
|
||||
@@ -629,6 +693,7 @@ class ChatItemArchiveImporter(
|
||||
this.stickerMessage != null -> this.stickerMessage.reactions
|
||||
this.viewOnceMessage != null -> this.viewOnceMessage.reactions
|
||||
this.directStoryReplyMessage != null -> this.directStoryReplyMessage.reactions
|
||||
this.poll != null -> this.poll.reactions
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
@@ -774,6 +839,9 @@ class ChatItemArchiveImporter(
|
||||
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
|
||||
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
|
||||
}
|
||||
updateMessage.pollTerminate != null -> {
|
||||
typeFlags = MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
}
|
||||
updateMessage.sessionSwitchover != null -> {
|
||||
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode()
|
||||
@@ -1004,6 +1072,7 @@ class ChatItemArchiveImporter(
|
||||
Quote.Type.NORMAL -> QuoteModel.Type.NORMAL.code
|
||||
Quote.Type.GIFT_BADGE -> QuoteModel.Type.GIFT_BADGE.code
|
||||
Quote.Type.VIEW_ONCE -> QuoteModel.Type.NORMAL.code
|
||||
Quote.Type.POLL -> QuoteModel.Type.POLL.code
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1210,8 +1279,7 @@ class ChatItemArchiveImporter(
|
||||
|
||||
private class MessageInsert(
|
||||
val contentValues: ContentValues,
|
||||
val followUp: ((Long) -> Unit)?,
|
||||
val edits: List<MessageInsert>? = null
|
||||
val followUp: ((Long) -> Unit)?
|
||||
)
|
||||
|
||||
private class Buffer(
|
||||
|
||||
@@ -8,8 +8,10 @@ package org.thoughtcrime.securesms.backup.v2.importer
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.util.toLocal
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
@@ -28,14 +30,22 @@ import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
* Handles the importing of [Contact] models into the local database.
|
||||
*/
|
||||
object ContactArchiveImporter {
|
||||
fun import(contact: Contact): RecipientId {
|
||||
private val TAG = Log.tag(ContactArchiveImporter::class)
|
||||
|
||||
fun import(contact: Contact): RecipientId? {
|
||||
val aci = ACI.parseOrNull(contact.aci?.toByteArray())
|
||||
val pni = PNI.parseOrNull(contact.pni?.toByteArray())
|
||||
val e164 = contact.formattedE164
|
||||
|
||||
if (aci == null && pni == null && e164 == null) {
|
||||
Log.w(TAG, ImportSkips.recipientWithoutId())
|
||||
return null
|
||||
}
|
||||
|
||||
val id = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(
|
||||
aci = aci,
|
||||
pni = pni,
|
||||
e164 = contact.formattedE164
|
||||
e164 = e164
|
||||
)
|
||||
|
||||
val profileKey = contact.profileKey?.toByteArray()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getAdhocCallsForBackup
|
||||
@@ -28,7 +29,7 @@ object AdHocCallArchiveProcessor {
|
||||
if (exportState.recipientIds.contains(callLog.recipientId)) {
|
||||
emitter.emit(Frame(adHocCall = callLog))
|
||||
} else {
|
||||
Log.w(TAG, "Dropping adhoc call for non-exported recipient.")
|
||||
Log.w(TAG, ExportSkips.callWithMissingRecipient(callLog.callTimestamp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.importer.ChatArchiveImporter
|
||||
@@ -39,11 +40,16 @@ object ChatArchiveProcessor {
|
||||
fun import(chat: Chat, importState: ImportState) {
|
||||
val recipientId: RecipientId? = importState.remoteToLocalRecipientId[chat.recipientId]
|
||||
if (recipientId == null) {
|
||||
Log.w(TAG, "Missing recipient for chat ${chat.id}")
|
||||
Log.w(TAG, ImportSkips.missingChatRecipient(chat.id))
|
||||
return
|
||||
}
|
||||
|
||||
val threadId = ChatArchiveImporter.import(chat, recipientId, importState)
|
||||
if (threadId == null) {
|
||||
Log.w(TAG, ImportSkips.failedToCreateChat())
|
||||
return
|
||||
}
|
||||
|
||||
importState.chatIdToLocalRecipientId[chat.id] = recipientId
|
||||
importState.chatIdToLocalThreadId[chat.id] = threadId
|
||||
importState.chatIdToBackupRecipientId[chat.id] = chat.recipientId
|
||||
|
||||
@@ -29,9 +29,9 @@ import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder as ChatFolderProto
|
||||
/**
|
||||
* Handles exporting and importing [ChatFolderRecord]s.
|
||||
*/
|
||||
object ChatFolderProcessor {
|
||||
object ChatFolderArchiveProcessor {
|
||||
|
||||
private val TAG = Log.tag(ChatFolderProcessor::class)
|
||||
private val TAG = Log.tag(ChatFolderArchiveProcessor::class)
|
||||
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
val folders = db
|
||||
@@ -113,10 +113,10 @@ object ChatFolderProcessor {
|
||||
private fun ChatFolderRecord.toBackupFrame(includedRecipientIds: List<Long>, excludedRecipientIds: List<Long>): Frame {
|
||||
val chatFolder = ChatFolderProto(
|
||||
name = this.name,
|
||||
showOnlyUnread = this.showUnread,
|
||||
showMutedChats = this.showMutedChats,
|
||||
includeAllIndividualChats = this.showIndividualChats,
|
||||
includeAllGroupChats = this.showGroupChats,
|
||||
showOnlyUnread = this.showUnread && this.folderType != ChatFolderRecord.FolderType.ALL,
|
||||
showMutedChats = this.showMutedChats || this.folderType == ChatFolderRecord.FolderType.ALL,
|
||||
includeAllIndividualChats = this.showIndividualChats || this.folderType == ChatFolderRecord.FolderType.ALL,
|
||||
includeAllGroupChats = this.showGroupChats || this.folderType == ChatFolderRecord.FolderType.ALL,
|
||||
folderType = when (this.folderType) {
|
||||
ChatFolderRecord.FolderType.ALL -> ChatFolderProto.FolderType.ALL
|
||||
ChatFolderRecord.FolderType.CUSTOM -> ChatFolderProto.FolderType.CUSTOM
|
||||
@@ -31,9 +31,9 @@ import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as Notific
|
||||
/**
|
||||
* Handles exporting and importing [NotificationProfile] models.
|
||||
*/
|
||||
object NotificationProfileProcessor {
|
||||
object NotificationProfileArchiveProcessor {
|
||||
|
||||
private val TAG = Log.tag(NotificationProfileProcessor::class)
|
||||
private val TAG = Log.tag(NotificationProfileArchiveProcessor::class)
|
||||
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
db.notificationProfileTables
|
||||
@@ -98,7 +98,7 @@ object RecipientArchiveProcessor {
|
||||
}
|
||||
|
||||
fun import(recipient: ArchiveRecipient, importState: ImportState) {
|
||||
val newId = when {
|
||||
val newId: RecipientId? = when {
|
||||
recipient.contact != null -> ContactArchiveImporter.import(recipient.contact)
|
||||
recipient.group != null -> GroupArchiveImporter.import(recipient.group)
|
||||
recipient.distributionList != null -> DistributionListArchiveImporter.import(recipient.distributionList, importState)
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -15,6 +18,7 @@ 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.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -72,20 +76,19 @@ fun MessageBackupsKeyEducationScreen(
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
InfoRow(
|
||||
R.drawable.symbol_number_24,
|
||||
R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__if_you_forget_your_key),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
InfoRow(
|
||||
R.drawable.symbol_lock_24,
|
||||
R.string.MessageBackupsKeyEducationScreen__store_your_recovery
|
||||
)
|
||||
|
||||
InfoRow(
|
||||
R.drawable.symbol_error_circle_24,
|
||||
R.string.MessageBackupsKeyEducationScreen__if_you_lose_it
|
||||
)
|
||||
|
||||
Spacer(
|
||||
@@ -101,10 +104,11 @@ fun MessageBackupsKeyEducationScreen(
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClick,
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__next)
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__view_recovery_key),
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +116,27 @@ fun MessageBackupsKeyEducationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(@DrawableRes iconId: Int, @StringRes textId: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(iconId),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
)
|
||||
Text(
|
||||
text = stringResource(textId),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenPreview() {
|
||||
|
||||
@@ -11,11 +11,11 @@ import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupK
|
||||
* View model for [ForgotBackupKeyFragment]
|
||||
*/
|
||||
class ForgotBackupKeyViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
private val _uiState = MutableStateFlow(BackupKeyDisplayUiState())
|
||||
val uiState: StateFlow<BackupKeyDisplayUiState> = _uiState
|
||||
private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState())
|
||||
val uiState: StateFlow<BackupKeyDisplayUiState> = internalUiState
|
||||
|
||||
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
|
||||
_uiState.update { it.copy(keySaveState = newState) }
|
||||
internalUiState.update { it.copy(keySaveState = newState) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ private fun AccountData.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAtt
|
||||
}
|
||||
|
||||
private fun Chat.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttachmentInfo> {
|
||||
val info = this.style?.wallpaperPhoto?.toArchiveAttachmentInfo()
|
||||
val info = this.style?.wallpaperPhoto?.toArchiveAttachmentInfo(isWallpaper = true)
|
||||
|
||||
return if (info != null) {
|
||||
setOf(info)
|
||||
@@ -73,7 +73,7 @@ private fun ChatItem.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttach
|
||||
return out ?: emptySet()
|
||||
}
|
||||
|
||||
private fun FilePointer.toArchiveAttachmentInfo(forQuote: Boolean = false): ArchiveAttachmentInfo? {
|
||||
private fun FilePointer.toArchiveAttachmentInfo(forQuote: Boolean = false, isWallpaper: Boolean = false): ArchiveAttachmentInfo? {
|
||||
if (this.locatorInfo?.key == null) {
|
||||
return null
|
||||
}
|
||||
@@ -87,7 +87,8 @@ private fun FilePointer.toArchiveAttachmentInfo(forQuote: Boolean = false): Arch
|
||||
remoteKey = this.locatorInfo.key,
|
||||
cdn = this.locatorInfo.mediaTierCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
contentType = this.contentType,
|
||||
forQuote = forQuote
|
||||
forQuote = forQuote,
|
||||
isWallpaper = isWallpaper
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +97,8 @@ data class ArchiveAttachmentInfo(
|
||||
val remoteKey: ByteString,
|
||||
val cdn: Int,
|
||||
val contentType: String?,
|
||||
val forQuote: Boolean
|
||||
val forQuote: Boolean,
|
||||
val isWallpaper: Boolean = false
|
||||
) {
|
||||
val fullSizeMediaName: MediaName get() = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash.toByteArray(), remoteKey.toByteArray())
|
||||
val thumbnailMediaName: MediaName get() = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(plaintextHash.toByteArray(), remoteKey.toByteArray())
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.banner.banners
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
|
||||
class DeprecatedSdkBanner() : Banner<Unit>() {
|
||||
|
||||
override val enabled: Boolean
|
||||
get() = Build.VERSION.SDK_INT < 23
|
||||
|
||||
override val dataFlow: Flow<Unit> = flowOf(Unit)
|
||||
|
||||
@Composable
|
||||
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) = Banner(contentPadding)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Banner(contentPadding: PaddingValues) {
|
||||
DefaultBanner(
|
||||
title = null,
|
||||
body = stringResource(id = R.string.DeprecatedSdkBanner_body),
|
||||
importance = Importance.ERROR,
|
||||
paddingValues = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun BannerPreview() {
|
||||
Previews.Preview {
|
||||
Banner(contentPadding = PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -172,10 +173,9 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
ContactSelectionListFragment fragment = new ContactSelectionListFragment();
|
||||
Intent intent = getIntent();
|
||||
|
||||
intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false);
|
||||
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
|
||||
intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
|
||||
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
|
||||
intent.putExtra(ContactSelectionArguments.REFRESHABLE, false);
|
||||
intent.putExtra(ContactSelectionArguments.SELECTION_LIMITS, 1);
|
||||
intent.putExtra(ContactSelectionArguments.DISPLAY_MODE,
|
||||
ContactSelectionDisplayMode.FLAG_PUSH |
|
||||
ContactSelectionDisplayMode.FLAG_SMS |
|
||||
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,7 +48,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.isSplitPane
|
||||
|
||||
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
|
||||
|
||||
@@ -109,7 +110,7 @@ fun EditCallLinkNameScreen(
|
||||
onNavigationClick = {
|
||||
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
|
||||
},
|
||||
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
|
||||
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -55,7 +56,7 @@ import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.isSplitPane
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
@@ -84,7 +85,7 @@ fun CallLinkDetailsScreen(
|
||||
state = state,
|
||||
showAlreadyInACall = showAlreadyInACall,
|
||||
callback = callback,
|
||||
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
|
||||
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ class CallLogAdapter(
|
||||
fun submitCallRows(
|
||||
rows: List<CallLogRow?>,
|
||||
selectionState: CallLogSelectionState,
|
||||
activeCallLogRowId: CallLogRow.Id?,
|
||||
localCallRecipientId: RecipientId,
|
||||
onCommit: () -> Unit
|
||||
): Int {
|
||||
@@ -99,8 +100,19 @@ class CallLogAdapter(
|
||||
.filterNotNull()
|
||||
.map {
|
||||
when (it) {
|
||||
is CallLogRow.Call -> CallModel(it, selectionState, itemCount, it.peer.id == localCallRecipientId)
|
||||
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount, it.recipient.id == localCallRecipientId)
|
||||
is CallLogRow.Call -> CallModel(
|
||||
call = it,
|
||||
selectionState = selectionState,
|
||||
itemCount = itemCount,
|
||||
isLocalDeviceInCall = it.peer.id == localCallRecipientId
|
||||
)
|
||||
is CallLogRow.CallLink -> CallLinkModel(
|
||||
callLink = it,
|
||||
selectionState = selectionState,
|
||||
activeCallLogRowId = activeCallLogRowId,
|
||||
itemCount = itemCount,
|
||||
isLocalDeviceInCall = it.recipient.id == localCallRecipientId
|
||||
)
|
||||
is CallLogRow.ClearFilter -> ClearFilterModel()
|
||||
is CallLogRow.ClearFilterEmpty -> ClearFilterEmptyModel()
|
||||
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
|
||||
@@ -148,6 +160,7 @@ class CallLogAdapter(
|
||||
private class CallLinkModel(
|
||||
val callLink: CallLogRow.CallLink,
|
||||
val selectionState: CallLogSelectionState,
|
||||
val activeCallLogRowId: CallLogRow.Id?,
|
||||
val itemCount: Int,
|
||||
val isLocalDeviceInCall: Boolean
|
||||
) : MappingModel<CallLinkModel> {
|
||||
@@ -159,12 +172,13 @@ class CallLogAdapter(
|
||||
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
|
||||
return callLink == newItem.callLink &&
|
||||
isSelectionStateTheSame(newItem) &&
|
||||
isActiveIdStateTheSame(newItem) &&
|
||||
isItemCountTheSame(newItem) &&
|
||||
isLocalDeviceInCall == newItem.isLocalDeviceInCall
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: CallLinkModel): Any? {
|
||||
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
|
||||
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem) || !isActiveIdStateTheSame(newItem))) {
|
||||
PAYLOAD_SELECTION_STATE
|
||||
} else {
|
||||
null
|
||||
@@ -176,6 +190,13 @@ class CallLogAdapter(
|
||||
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
|
||||
}
|
||||
|
||||
private fun isActiveIdStateTheSame(newItem: CallLinkModel): Boolean {
|
||||
val isOldItemActive = activeCallLogRowId == callLink.id
|
||||
val isNewItemActive = newItem.activeCallLogRowId == newItem.callLink.id
|
||||
|
||||
return (isOldItemActive && isNewItemActive) || (!isOldItemActive && !isNewItemActive)
|
||||
}
|
||||
|
||||
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
|
||||
return itemCount == newItem.itemCount
|
||||
}
|
||||
@@ -220,6 +241,8 @@ class CallLogAdapter(
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
itemView.isActivated = model.activeCallLogRowId == model.callLink.id
|
||||
|
||||
if (payload.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.kotlin.Flowables
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -25,6 +26,7 @@ import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.MainNavigator
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment
|
||||
@@ -59,7 +61,8 @@ import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.doAfterNextLayout
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.getWindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.isSplitPane
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
@@ -122,12 +125,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
)
|
||||
|
||||
disposables += scrollToPositionDelegate
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected)
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected, mainNavigationViewModel.observableActiveCallId.toFlowable(BackpressureStrategy.LATEST))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (data, selected) ->
|
||||
.subscribe { (data, selected, activeRowId) ->
|
||||
val filteredCount = callLogAdapter.submitCallRows(
|
||||
data,
|
||||
selected,
|
||||
activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() },
|
||||
viewModel.callLogPeekHelper.localDeviceCallRecipientId,
|
||||
scrollToPositionDelegate::notifyListCommitted
|
||||
)
|
||||
@@ -139,6 +143,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (selected, totalCount) ->
|
||||
if (selected.isNotEmpty(totalCount)) {
|
||||
callLogActionMode.start()
|
||||
callLogActionMode.setCount(selected.count(totalCount))
|
||||
} else if (mainToolbarViewModel.isInActionMode()) {
|
||||
callLogActionMode.end()
|
||||
@@ -180,7 +185,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
if (resources.getWindowSizeClass().isCompact()) {
|
||||
if (!resources.getWindowSizeClass().isSplitPane()) {
|
||||
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
|
||||
}
|
||||
|
||||
@@ -203,7 +208,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
|
||||
private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) {
|
||||
disposables += mainNavigationViewModel.tabClickEvents
|
||||
disposables += mainNavigationViewModel.tabClickEventsObservable
|
||||
.filter { it == MainNavigationListLocation.CALLS }
|
||||
.subscribeBy(onNext = {
|
||||
scrollToPositionDelegate.resetScrollPosition()
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -100,7 +101,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, NewCallActivity::class.java)
|
||||
.putExtra(
|
||||
ContactSelectionListFragment.DISPLAY_MODE,
|
||||
ContactSelectionArguments.DISPLAY_MODE,
|
||||
ContactSelectionDisplayMode.none()
|
||||
.withPush()
|
||||
.withActiveGroups()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.quality
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Fragment which manages sheets for walking the user through collecting call
|
||||
* quality feedback.
|
||||
*/
|
||||
class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "CallQualityBottomSheetRequestKey"
|
||||
}
|
||||
|
||||
private val viewModel: CallQualityScreenViewModel by viewModel {
|
||||
CallQualityScreenViewModel()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setFragmentResultListener(CallQualitySomethingElseFragment.REQUEST_KEY) { key, bundle ->
|
||||
val result = bundle.getString(CallQualitySomethingElseFragment.REQUEST_KEY) ?: ""
|
||||
|
||||
viewModel.onSomethingElseDescriptionChanged(result)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
CallQualitySheet(
|
||||
state = state,
|
||||
callback = remember { Callback() }
|
||||
)
|
||||
}
|
||||
|
||||
private inner class Callback : CallQualitySheetCallback {
|
||||
override fun dismiss() {
|
||||
this@CallQualityBottomSheetFragment.dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
override fun viewDebugLog() {
|
||||
startActivity(
|
||||
Intent(requireContext(), SubmitDebugLogActivity::class.java).apply {
|
||||
putExtra(SubmitDebugLogActivity.ARG_VIEW_ONLY, true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun describeYourIssue() {
|
||||
CallQualitySomethingElseFragment.create(
|
||||
viewModel.state.value.somethingElseDescription
|
||||
).show(parentFragmentManager, null)
|
||||
}
|
||||
|
||||
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
|
||||
viewModel.onCallQualityIssueSelectionChanged(selection)
|
||||
}
|
||||
|
||||
override fun onShareDebugLogChanged(shareDebugLog: Boolean) {
|
||||
viewModel.onShareDebugLogChanged(shareDebugLog)
|
||||
}
|
||||
|
||||
override fun submit() {
|
||||
viewModel.submit()
|
||||
dismiss()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.quality
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class CallQualityScreenViewModel : ViewModel() {
|
||||
|
||||
private val internalState = MutableStateFlow(CallQualitySheetState())
|
||||
val state: StateFlow<CallQualitySheetState> = internalState
|
||||
|
||||
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
|
||||
internalState.update { it.copy(selectedQualityIssues = selection) }
|
||||
}
|
||||
|
||||
fun onSomethingElseDescriptionChanged(somethingElseDescription: String) {
|
||||
internalState.update { it.copy(somethingElseDescription = somethingElseDescription) }
|
||||
}
|
||||
|
||||
fun onShareDebugLogChanged(shareDebugLog: Boolean) {
|
||||
internalState.update { it.copy(isShareDebugLogSelected = shareDebugLog) }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
// Enqueue job.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.quality
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.InputChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.IconButtons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CallQualitySheet(
|
||||
state: CallQualitySheetState = remember { CallQualitySheetState() },
|
||||
callback: CallQualitySheetCallback = CallQualitySheetCallback.Empty
|
||||
) {
|
||||
var navEntry: CallQualitySheetNavEntry by remember { mutableStateOf(CallQualitySheetNavEntry.HowWasYourCall) }
|
||||
|
||||
when (navEntry) {
|
||||
CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall(
|
||||
onGreatClick = {
|
||||
navEntry = CallQualitySheetNavEntry.HelpUsImprove
|
||||
},
|
||||
onHadIssuesClick = {
|
||||
navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave
|
||||
},
|
||||
onCancelClick = callback::dismiss
|
||||
)
|
||||
|
||||
CallQualitySheetNavEntry.WhatIssuesDidYouHave -> WhatIssuesDidYouHave(
|
||||
selectedQualityIssues = state.selectedQualityIssues,
|
||||
somethingElseDescription = state.somethingElseDescription,
|
||||
onCallQualityIssueSelectionChanged = callback::onCallQualityIssueSelectionChanged,
|
||||
onContinueClick = {
|
||||
navEntry = CallQualitySheetNavEntry.HelpUsImprove
|
||||
},
|
||||
onDescribeYourIssueClick = callback::describeYourIssue,
|
||||
onCancelClick = callback::dismiss
|
||||
)
|
||||
|
||||
CallQualitySheetNavEntry.HelpUsImprove -> HelpUsImprove(
|
||||
isShareDebugLogSelected = state.isShareDebugLogSelected,
|
||||
onViewDebugLogClick = callback::viewDebugLog,
|
||||
onCancelClick = callback::dismiss,
|
||||
onShareDebugLogChanged = callback::onShareDebugLogChanged,
|
||||
onSubmitClick = callback::submit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HowWasYourCall(
|
||||
onHadIssuesClick: () -> Unit,
|
||||
onGreatClick: () -> Unit,
|
||||
onCancelClick: () -> Unit
|
||||
) {
|
||||
Sheet(onDismissRequest = onCancelClick) {
|
||||
SheetTitle(text = stringResource(R.string.CallQualitySheet__how_was_your_call))
|
||||
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__how_was_your_call_subtitle))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp)
|
||||
) {
|
||||
HadIssuesButton(onClick = onHadIssuesClick)
|
||||
GreatButton(onClick = onGreatClick)
|
||||
}
|
||||
|
||||
CancelButton(
|
||||
onClick = onCancelClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 32.dp, bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WhatIssuesDidYouHave(
|
||||
selectedQualityIssues: Set<CallQualityIssue>,
|
||||
somethingElseDescription: String,
|
||||
onCallQualityIssueSelectionChanged: (Set<CallQualityIssue>) -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
onDescribeYourIssueClick: () -> Unit
|
||||
) {
|
||||
Sheet(onDismissRequest = onCancelClick) {
|
||||
SheetTitle(text = stringResource(R.string.CallQualitySheet__what_issues_did_you_have))
|
||||
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__select_all_that_apply))
|
||||
|
||||
val qualityIssueDisplaySet = rememberQualityDisplaySet(selectedQualityIssues)
|
||||
val onCallQualityIssueClick: (CallQualityIssue) -> Unit = remember(selectedQualityIssues, onCallQualityIssueSelectionChanged) {
|
||||
{ issue ->
|
||||
val isRemoving = issue in selectedQualityIssues
|
||||
val selection = when {
|
||||
isRemoving && issue == CallQualityIssue.AUDIO_ISSUE -> {
|
||||
selectedQualityIssues.filterNot { it.category == CallQualityIssueCategory.AUDIO }.toSet()
|
||||
}
|
||||
|
||||
isRemoving && issue == CallQualityIssue.VIDEO_ISSUE -> {
|
||||
selectedQualityIssues.filterNot { it.category == CallQualityIssueCategory.VIDEO }.toSet()
|
||||
}
|
||||
|
||||
isRemoving -> {
|
||||
selectedQualityIssues - issue
|
||||
}
|
||||
|
||||
else -> {
|
||||
selectedQualityIssues + issue
|
||||
}
|
||||
}
|
||||
|
||||
onCallQualityIssueSelectionChanged(selection)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
qualityIssueDisplaySet.forEach { issue ->
|
||||
val isIssueSelected = issue in selectedQualityIssues
|
||||
|
||||
InputChip(
|
||||
selected = isIssueSelected,
|
||||
onClick = {
|
||||
onCallQualityIssueClick(issue)
|
||||
},
|
||||
colors = InputChipDefaults.inputChipColors(
|
||||
leadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
labelColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isIssueSelected) {
|
||||
ImageVector.vectorResource(R.drawable.symbol_check_24)
|
||||
} else {
|
||||
ImageVector.vectorResource(issue.category.icon)
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(issue.label))
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues) {
|
||||
val text = somethingElseDescription.ifEmpty {
|
||||
stringResource(R.string.CallQualitySheet__describe_your_issue)
|
||||
}
|
||||
|
||||
val textColor = if (somethingElseDescription.isNotEmpty()) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
val textUnderlineStrokeWidthPx = with(LocalDensity.current) {
|
||||
1.dp.toPx()
|
||||
}
|
||||
|
||||
val textUnderlineColor = MaterialTheme.colorScheme.outline
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
onClick = onDescribeYourIssueClick
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val width = size.width
|
||||
val height = size.height - textUnderlineStrokeWidthPx / 2f
|
||||
|
||||
drawLine(
|
||||
color = textUnderlineColor,
|
||||
start = Offset(x = 0f, y = height),
|
||||
end = Offset(x = width, y = height),
|
||||
strokeWidth = textUnderlineStrokeWidthPx
|
||||
)
|
||||
}
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 32.dp, bottom = 24.dp)
|
||||
) {
|
||||
CancelButton(
|
||||
onClick = onCancelClick
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onContinueClick
|
||||
) {
|
||||
Text(text = stringResource(R.string.CallQualitySheet__continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HelpUsImprove(
|
||||
isShareDebugLogSelected: Boolean,
|
||||
onShareDebugLogChanged: (Boolean) -> Unit,
|
||||
onViewDebugLogClick: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onSubmitClick: () -> Unit
|
||||
) {
|
||||
Sheet(onDismissRequest = onCancelClick) {
|
||||
SheetTitle(text = stringResource(R.string.CallQualitySheet__help_us_improve))
|
||||
SheetSubtitle(
|
||||
text = buildAnnotatedString {
|
||||
append(stringResource(R.string.CallQualitySheet__help_us_improve_description_prefix))
|
||||
append(" ")
|
||||
|
||||
withLink(
|
||||
link = LinkAnnotation.Clickable(
|
||||
"view-your-debug-log",
|
||||
linkInteractionListener = { onViewDebugLogClick() },
|
||||
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary))
|
||||
)
|
||||
) {
|
||||
append(stringResource(R.string.CallQualitySheet__view_your_debug_log))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.CallQualitySheet__help_us_improve_description_suffix))
|
||||
}
|
||||
)
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = isShareDebugLogSelected,
|
||||
text = stringResource(R.string.CallQualitySheet__share_debug_log),
|
||||
onCheckChanged = onShareDebugLogChanged
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.CallQualitySheet__debug_log_privacy_notice),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.horizontalGutters().padding(top = 14.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(top = 32.dp, bottom = 24.dp)
|
||||
) {
|
||||
CancelButton(
|
||||
onClick = onCancelClick
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onSubmitClick
|
||||
) {
|
||||
Text(text = stringResource(R.string.CallQualitySheet__submit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Sheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
dragHandle = null,
|
||||
sheetGesturesEnabled = false,
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetTitle(
|
||||
text: String
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 46.dp, bottom = 10.dp)
|
||||
.horizontalGutters()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetSubtitle(
|
||||
text: String
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetSubtitle(
|
||||
text: AnnotatedString
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HadIssuesButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FeedbackButton(
|
||||
text = stringResource(R.string.CallQualitySheet__had_issues),
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GreatButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FeedbackButton(
|
||||
text = stringResource(R.string.CallQualitySheet__great),
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedbackButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
containerColor: Color,
|
||||
contentColor: Color
|
||||
// imageVector icon
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = spacedBy(12.dp)
|
||||
) {
|
||||
IconButtons.IconButton(
|
||||
onClick = onClick,
|
||||
size = 72.dp,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(color = containerColor)
|
||||
) {
|
||||
// TODO - icon with contentcolor tint
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextButton(onClick = onClick, modifier = modifier) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun CallQualityScreenPreview() {
|
||||
var state by remember { mutableStateOf(CallQualitySheetState()) }
|
||||
|
||||
Previews.Preview {
|
||||
CallQualitySheet(
|
||||
state = state,
|
||||
callback = remember {
|
||||
object : CallQualitySheetCallback by CallQualitySheetCallback.Empty {
|
||||
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
|
||||
state = state.copy(selectedQualityIssues = selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun HowWasYourCallPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Column {
|
||||
HowWasYourCall(
|
||||
onGreatClick = {},
|
||||
onCancelClick = {},
|
||||
onHadIssuesClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun WhatIssuesDidYouHavePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
var userSelection by remember { mutableStateOf<Set<CallQualityIssue>>(emptySet()) }
|
||||
|
||||
Column {
|
||||
WhatIssuesDidYouHave(
|
||||
selectedQualityIssues = userSelection,
|
||||
somethingElseDescription = "",
|
||||
onCallQualityIssueSelectionChanged = {
|
||||
userSelection = it
|
||||
},
|
||||
onCancelClick = {},
|
||||
onContinueClick = {},
|
||||
onDescribeYourIssueClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun HelpUsImprovePreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
Column {
|
||||
HelpUsImprove(
|
||||
isShareDebugLogSelected = true,
|
||||
onViewDebugLogClick = {},
|
||||
onCancelClick = {},
|
||||
onShareDebugLogChanged = {},
|
||||
onSubmitClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun SomethingElseContentPreview() {
|
||||
Previews.Preview {
|
||||
CallQualitySomethingElseScreen(
|
||||
somethingElseDescription = "About 5 minutes into a call with my friend",
|
||||
onCancelClick = {},
|
||||
onSaveClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberQualityDisplaySet(userSelection: Set<CallQualityIssue>): Set<CallQualityIssue> {
|
||||
return remember(userSelection) {
|
||||
val displaySet = mutableSetOf<CallQualityIssue>()
|
||||
displaySet.add(CallQualityIssue.AUDIO_ISSUE)
|
||||
if (CallQualityIssue.AUDIO_ISSUE in userSelection) {
|
||||
displaySet.add(CallQualityIssue.AUDIO_STUTTERING)
|
||||
displaySet.add(CallQualityIssue.AUDIO_CUT_OUT)
|
||||
displaySet.add(CallQualityIssue.AUDIO_I_HEARD_ECHO)
|
||||
displaySet.add(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO)
|
||||
}
|
||||
|
||||
displaySet.add(CallQualityIssue.VIDEO_ISSUE)
|
||||
if (CallQualityIssue.VIDEO_ISSUE in userSelection) {
|
||||
displaySet.add(CallQualityIssue.VIDEO_POOR_QUALITY)
|
||||
displaySet.add(CallQualityIssue.VIDEO_LOW_RESOLUTION)
|
||||
displaySet.add(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION)
|
||||
}
|
||||
|
||||
displaySet.add(CallQualityIssue.CALL_DROPPED)
|
||||
displaySet.add(CallQualityIssue.SOMETHING_ELSE)
|
||||
|
||||
displaySet
|
||||
}
|
||||
}
|
||||
|
||||
data class CallQualitySheetState(
|
||||
val selectedQualityIssues: Set<CallQualityIssue> = emptySet(),
|
||||
val somethingElseDescription: String = "",
|
||||
val isShareDebugLogSelected: Boolean = false
|
||||
)
|
||||
|
||||
interface CallQualitySheetCallback {
|
||||
fun dismiss()
|
||||
fun viewDebugLog()
|
||||
fun describeYourIssue()
|
||||
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>)
|
||||
fun onShareDebugLogChanged(shareDebugLog: Boolean)
|
||||
fun submit()
|
||||
|
||||
object Empty : CallQualitySheetCallback {
|
||||
override fun dismiss() = Unit
|
||||
override fun viewDebugLog() = Unit
|
||||
override fun describeYourIssue() = Unit
|
||||
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) = Unit
|
||||
override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit
|
||||
override fun submit() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CallQualitySheetNavEntry {
|
||||
HowWasYourCall,
|
||||
WhatIssuesDidYouHave,
|
||||
HelpUsImprove
|
||||
}
|
||||
|
||||
enum class CallQualityIssueCategory(
|
||||
@param:DrawableRes val icon: Int
|
||||
) {
|
||||
AUDIO(icon = R.drawable.symbol_speaker_24),
|
||||
VIDEO(icon = R.drawable.symbol_video_24),
|
||||
CALL_DROPPED(icon = R.drawable.symbol_x_circle_24),
|
||||
SOMETHING_ELSE(icon = R.drawable.symbol_error_circle_24)
|
||||
}
|
||||
|
||||
enum class CallQualityIssue(
|
||||
val category: CallQualityIssueCategory,
|
||||
@param:StringRes val label: Int
|
||||
) {
|
||||
AUDIO_ISSUE(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue),
|
||||
AUDIO_STUTTERING(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_stuttering),
|
||||
AUDIO_CUT_OUT(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_cut_out),
|
||||
AUDIO_I_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__i_heard_echo),
|
||||
AUDIO_OTHERS_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__others_heard_echo),
|
||||
VIDEO_ISSUE(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue),
|
||||
VIDEO_POOR_QUALITY(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality),
|
||||
VIDEO_LOW_RESOLUTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__low_resolution),
|
||||
VIDEO_CAMERA_MALFUNCTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__camera_did_not_work),
|
||||
CALL_DROPPED(category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped),
|
||||
SOMETHING_ELSE(category = CallQualityIssueCategory.SOMETHING_ELSE, label = R.string.CallQualityIssue__something_else)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.quality
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.thoughtcrime.securesms.compose.ComposeFullScreenDialogFragment
|
||||
|
||||
/**
|
||||
* Fragment which allows user to enter additional text to describe a call issue.
|
||||
*/
|
||||
class CallQualitySomethingElseFragment : ComposeFullScreenDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "CallQualitySomethingElseRequestKey"
|
||||
|
||||
fun create(somethingElseDescription: String): DialogFragment {
|
||||
return CallQualitySomethingElseFragment().apply {
|
||||
arguments = bundleOf(REQUEST_KEY to somethingElseDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
|
||||
dialog.window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
val initialState = remember { requireArguments().getString(REQUEST_KEY) ?: "" }
|
||||
|
||||
CallQualitySomethingElseScreen(
|
||||
somethingElseDescription = initialState,
|
||||
onSaveClick = {
|
||||
dismissAllowingStateLoss()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
},
|
||||
onCancelClick = {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.quality
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.TextFields
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
@Composable
|
||||
fun CallQualitySomethingElseScreen(
|
||||
somethingElseDescription: String,
|
||||
onCancelClick: () -> Unit,
|
||||
onSaveClick: (String) -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.CallQualitySomethingElseScreen__title),
|
||||
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
|
||||
onNavigationClick = onCancelClick,
|
||||
navigationContentDescription = stringResource(R.string.CallQualitySomethingElseScreen__back),
|
||||
modifier = Modifier.imePadding()
|
||||
) { paddingValues ->
|
||||
|
||||
var issue by remember { mutableStateOf(somethingElseDescription) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
TextFields.TextField(
|
||||
label = {
|
||||
Text(stringResource(R.string.CallQualitySomethingElseScreen__describe_your_issue))
|
||||
},
|
||||
value = issue,
|
||||
minLines = 4,
|
||||
maxLines = 4,
|
||||
onValueChange = {
|
||||
issue = it
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.CallQualitySomethingElseScreen__privacy_notice),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.horizontalGutters()
|
||||
.padding(top = 24.dp, bottom = 32.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
CancelButton(
|
||||
onClick = onCancelClick
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onSaveClick(issue) }
|
||||
) {
|
||||
Text(text = stringResource(R.string.CallQualitySomethingElseScreen__save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import android.view.inputmethod.InputConnection;
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
@@ -236,7 +237,7 @@ public class ComposeText extends EmojiEditText {
|
||||
return null;
|
||||
}
|
||||
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif" });
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" });
|
||||
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
|
||||
}
|
||||
|
||||
@@ -477,16 +478,20 @@ public class ComposeText extends EmojiEditText {
|
||||
/**
|
||||
* Return true if we think the user may be inputting a time.
|
||||
*/
|
||||
private static boolean couldBeTimeEntry(@NonNull CharSequence text, int startIndex) {
|
||||
@VisibleForTesting
|
||||
static boolean couldBeTimeEntry(@NonNull CharSequence text, int startIndex) {
|
||||
if (startIndex <= 0 || startIndex + 1 >= text.length()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int startOfToken = startIndex;
|
||||
while (startOfToken > 0 && !Character.isWhitespace(text.charAt(startOfToken))) {
|
||||
startOfToken--;
|
||||
while (startOfToken > 0) {
|
||||
int prevIndex = startOfToken - 1;
|
||||
if (Character.isWhitespace(text.charAt(prevIndex))) {
|
||||
break;
|
||||
}
|
||||
startOfToken = prevIndex;
|
||||
}
|
||||
startOfToken++;
|
||||
|
||||
int endOfToken = startIndex;
|
||||
while (endOfToken < text.length() && !Character.isWhitespace(text.charAt(endOfToken))) {
|
||||
|
||||
@@ -27,9 +27,10 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* A search input field for finding recipients.
|
||||
* <p>
|
||||
* In compose, use RecipientSearchField instead.
|
||||
*
|
||||
* @deprecated Use the RecipientSearchBar composable instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public final class ContactFilterView extends FrameLayout {
|
||||
private OnFilterChangedListener listener;
|
||||
|
||||
@@ -147,6 +148,11 @@ public final class ContactFilterView extends FrameLayout {
|
||||
ViewUtil.focusAndShowKeyboard(searchText);
|
||||
}
|
||||
|
||||
public void setText(String text) {
|
||||
searchText.setText(text);
|
||||
searchText.setSelection(text.length());
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
searchText.setText("");
|
||||
notifyListener();
|
||||
|
||||
@@ -19,6 +19,9 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.WindowUtil
|
||||
import org.thoughtcrime.securesms.window.getWindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.isLargeScreenSupportEnabled
|
||||
import org.thoughtcrime.securesms.window.isSplitPane
|
||||
import com.google.android.material.R as MaterialR
|
||||
|
||||
/**
|
||||
@@ -26,6 +29,17 @@ import com.google.android.material.R as MaterialR
|
||||
*/
|
||||
abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() {
|
||||
|
||||
/**
|
||||
* Sheet corner radius in DP
|
||||
*/
|
||||
protected val cornerRadius: Int by lazy {
|
||||
if (isLargeScreenSupportEnabled() && resources.getWindowSizeClass().isSplitPane()) {
|
||||
32
|
||||
} else {
|
||||
18
|
||||
}
|
||||
}
|
||||
|
||||
protected open val peekHeightPercentage: Float = 0.5f
|
||||
|
||||
@StyleRes
|
||||
@@ -54,8 +68,8 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
|
||||
|
||||
val shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
|
||||
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
|
||||
.setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), cornerRadius).toFloat())
|
||||
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), cornerRadius).toFloat())
|
||||
.build()
|
||||
|
||||
dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
/**
|
||||
* Base dialog fragment for rendering as a full screen dialog with animation
|
||||
@@ -43,6 +44,12 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
|
||||
}
|
||||
|
||||
protected void onNavigateUp() {
|
||||
dismissAllowingStateLoss();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
@@ -13,9 +14,8 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.main.InsetsViewModel
|
||||
import org.thoughtcrime.securesms.main.VerticalInsets
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -66,7 +66,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
|
||||
private var insets: WindowInsetsCompat? = null
|
||||
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
|
||||
private var verticalInsetOverride: InsetsViewModel.Insets = InsetsViewModel.Insets.Zero
|
||||
private var verticalInsetOverride: VerticalInsets = VerticalInsets.Zero
|
||||
|
||||
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
|
||||
this.insets = insets
|
||||
@@ -130,7 +130,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun applyInsets(insets: InsetsViewModel.Insets) {
|
||||
fun applyInsets(insets: VerticalInsets) {
|
||||
verticalInsetOverride = insets
|
||||
|
||||
if (this.insets != null) {
|
||||
@@ -138,14 +138,6 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVerticalInsetOverride() {
|
||||
verticalInsetOverride = InsetsViewModel.Insets.Zero
|
||||
|
||||
if (this.insets != null) {
|
||||
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
|
||||
}
|
||||
}
|
||||
|
||||
fun addKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
keyboardStateListeners += listener
|
||||
}
|
||||
@@ -165,8 +157,8 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
val statusBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.top else verticalInsetOverride.statusBar.roundToInt()
|
||||
val navigationBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.bottom else verticalInsetOverride.navBar.roundToInt()
|
||||
val statusBar = if (verticalInsetOverride == VerticalInsets.Zero) windowInsets.top else verticalInsetOverride.statusBar.roundToInt()
|
||||
val navigationBar = if (verticalInsetOverride == VerticalInsets.Zero) windowInsets.bottom else verticalInsetOverride.navBar.roundToInt()
|
||||
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
|
||||
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
|
||||
|
||||
@@ -190,9 +182,9 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
} else if (!overridingKeyboard) {
|
||||
if (!keyboardAnimator.animating) {
|
||||
keyboardGuideline?.setGuidelineEnd(windowInsets.bottom)
|
||||
keyboardGuideline?.setGuidelineEnd(navigationBar)
|
||||
} else {
|
||||
keyboardAnimator.endingGuidelineEnd = windowInsets.bottom
|
||||
keyboardAnimator.endingGuidelineEnd = navigationBar
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +241,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun isLandscape(): Boolean {
|
||||
return resources.getWindowSizeClass().isLandscape()
|
||||
return resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
}
|
||||
|
||||
private val Guideline?.guidelineEnd: Int
|
||||
|
||||
@@ -4,6 +4,7 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -32,6 +33,7 @@ import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
@@ -43,6 +45,7 @@ import org.thoughtcrime.securesms.stories.StoryTextPostModel;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -231,7 +234,14 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
|
||||
}
|
||||
|
||||
private @Nullable CharSequence resolveBody(@Nullable CharSequence body, @NonNull QuoteModel.Type quoteType) {
|
||||
return quoteType == QuoteModel.Type.GIFT_BADGE ? getContext().getString(R.string.QuoteView__donation_for_a_friend) : body;
|
||||
switch (quoteType) {
|
||||
case GIFT_BADGE:
|
||||
return getContext().getString(R.string.QuoteView__donation_for_a_friend);
|
||||
case POLL:
|
||||
return getContext().getString(R.string.Poll__poll_question, body);
|
||||
default:
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
|
||||
@@ -317,6 +327,14 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
|
||||
Log.w(TAG, "Could not parse body of text post.", e);
|
||||
bodyView.setText("");
|
||||
}
|
||||
} else if (quoteType == QuoteModel.Type.POLL) {
|
||||
CharSequence glyph = SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.POLL, -1);
|
||||
// TODO(michelle): Update with RTL poll icon
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder()
|
||||
.append(glyph)
|
||||
.append(" ")
|
||||
.append(body);
|
||||
bodyView.setText(body == null ? "" : builder);
|
||||
} else {
|
||||
bodyView.setText(body == null ? "" : body);
|
||||
}
|
||||
@@ -404,7 +422,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(quoteTargetContentType) || slide == null || slide.getUri() == null) {
|
||||
if (TextUtils.isEmpty(quoteTargetContentType)) {
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
|
||||
@@ -431,12 +449,12 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
|
||||
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
requestManager.load(new DecryptableUri(slide.getUri()))
|
||||
requestManager.load(slide.getUri() != null ? new DecryptableUri(slide.getUri()) : null)
|
||||
.centerCrop()
|
||||
.override(thumbWidth, thumbHeight)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.into(thumbnailView);
|
||||
} else if (MediaUtil.isAudioType(quoteTargetContentType)) {
|
||||
} else if (MediaUtil.isAudioType(quoteTargetContentType) || MediaUtil.isLongTextType(quoteTargetContentType)) {
|
||||
thumbnailView.setVisibility(GONE);
|
||||
attachmentNameViewStub.setVisibility(GONE);
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import org.signal.core.ui.compose.IconButtons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* A consistent ActionMode top-bar for dealing with multiselect scenarios.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ActionModeTopBar(
|
||||
title: String,
|
||||
onCloseClick: () -> Unit,
|
||||
toolbarColor: Color? = null,
|
||||
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets
|
||||
) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = toolbarColor ?: MaterialTheme.colorScheme.surface
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButtons.IconButton(onClick = onCloseClick) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24),
|
||||
contentDescription = stringResource(R.string.CallScreenTopBar__go_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(text = title)
|
||||
},
|
||||
windowInsets = windowInsets
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun ActionModeTopBarPreview() {
|
||||
Previews.Preview {
|
||||
ActionModeTopBar(
|
||||
title = "1 selected",
|
||||
onCloseClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
/**
|
||||
* A View wrapper for [ActionModeTopBar] so that we can use the same UI element in View and Compose land.
|
||||
*/
|
||||
class ActionModeTopBarView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : AbstractComposeView(context, attrs, defStyleAttr) {
|
||||
|
||||
var title by mutableStateOf("")
|
||||
var onCloseClick: () -> Unit by mutableStateOf({})
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
ActionModeTopBar(
|
||||
title = title,
|
||||
toolbarColor = Color.Transparent,
|
||||
onCloseClick = onCloseClick,
|
||||
windowInsets = WindowInsets()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose
|
||||
|
||||
import android.os.Build
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
|
||||
|
||||
/**
|
||||
* When [enabled]=true, this function sets the [EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING] flag for all text fields within its content to enable the
|
||||
* incognito keyboard.
|
||||
*
|
||||
* This workaround is needed until it's possible to configure granular IME options for a [TextField].
|
||||
* https://issuetracker.google.com/issues/359257538
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ProvideIncognitoKeyboard(
|
||||
enabled: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
if (enabled) {
|
||||
InterceptPlatformTextInput(
|
||||
interceptor = { request, nextHandler ->
|
||||
val modifiedRequest = PlatformTextInputMethodRequest { outAttributes ->
|
||||
request.createInputConnection(outAttributes).also {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
outAttributes.imeOptions = outAttributes.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
|
||||
}
|
||||
}
|
||||
}
|
||||
nextHandler.startInputMethod(modifiedRequest)
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ package org.thoughtcrime.securesms.components.compose
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import org.thoughtcrime.securesms.window.isAtLeast
|
||||
|
||||
/**
|
||||
* Displays the screen title for split-pane UIs on tablets and foldable devices.
|
||||
@@ -21,7 +23,7 @@ fun ScreenTitlePane(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
@@ -29,9 +31,8 @@ fun ScreenTitlePane(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier
|
||||
.padding(
|
||||
start = if (windowSizeClass.isExtended()) 80.dp else 20.dp,
|
||||
start = if (windowSizeClass.windowWidthSizeClass.isAtLeast(WindowWidthSizeClass.EXPANDED)) 80.dp else 20.dp,
|
||||
end = 20.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.Annotation;
|
||||
import android.text.Layout;
|
||||
import android.text.PrecomputedText;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
@@ -29,10 +30,12 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.PrecomputedTextCompat;
|
||||
import androidx.core.view.GestureDetectorCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
@@ -42,10 +45,14 @@ import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
|
||||
|
||||
import java.lang.ref.Reference;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
@@ -85,6 +92,12 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private int lastSizeChangedWidth = -1;
|
||||
private int lastSizeChangedHeight = -1;
|
||||
|
||||
// Utilized for async text loading when a large number of emoji is present.
|
||||
private int taskNumber = 0;
|
||||
private Executor backgroundExecutor = new SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED);
|
||||
private CharSequence requestedText = null;
|
||||
private BufferType requestedType = null;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
|
||||
@@ -128,7 +141,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
public void setMaxLength(int maxLength) {
|
||||
this.maxLength = maxLength;
|
||||
setText(getText());
|
||||
setTextAsync(getText());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -162,8 +175,115 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended method for calling through to reset the text flow within this file.
|
||||
* Doing so will ensure we call setTextAsync with the requested arguments as necessary.
|
||||
*/
|
||||
private void resetText() {
|
||||
if (requestedText == null || requestedType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTextAsync(requestedText, requestedType);
|
||||
}
|
||||
|
||||
public void setTextAsync(@Nullable CharSequence text) {
|
||||
setTextAsync(text, BufferType.SPANNABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the text. If there are more than 100 emoji candidates, we utilize PrecomputedTextCompat.
|
||||
*/
|
||||
public void setTextAsync(@Nullable CharSequence text, BufferType type) {
|
||||
taskNumber++;
|
||||
final int number = taskNumber;
|
||||
|
||||
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
|
||||
if (candidates == null || candidates.size() <= 100) {
|
||||
setText(text, type);
|
||||
|
||||
if (sizeChangeInProgress) {
|
||||
sizeChangeInProgress = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
final PrecomputedTextCompat.Params params = getTextMetricsParamsCompat();
|
||||
final Reference<EmojiTextView> ref = new WeakReference<>(this);
|
||||
|
||||
backgroundExecutor.execute(() -> {
|
||||
EmojiTextView textView = ref.get();
|
||||
if (textView != null) {
|
||||
|
||||
final CharSequence textToSet;
|
||||
synchronized (textView) {
|
||||
textToSet = getTextToSet(text, type);
|
||||
}
|
||||
|
||||
if (textToSet == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final PrecomputedTextCompat precomputedTextCompat = PrecomputedTextCompat.create(textToSet, params);
|
||||
|
||||
textView.post(() -> {
|
||||
if (textView.taskNumber != number) {
|
||||
return;
|
||||
}
|
||||
|
||||
textView.setPrecomputedText(precomputedTextCompat);
|
||||
|
||||
if (textView.sizeChangeInProgress) {
|
||||
textView.sizeChangeInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Note if you aren't sure how many emoji are going to be displayed, it may be better to utilize [setTextAsync]
|
||||
*/
|
||||
@Override
|
||||
public void setText(@Nullable CharSequence text, BufferType type) {
|
||||
boolean isPrecomputed = (text instanceof PrecomputedTextCompat || (Build.VERSION.SDK_INT >= 28 && text instanceof PrecomputedText));
|
||||
if (!isPrecomputed) {
|
||||
text = getTextToSet(text, type);
|
||||
}
|
||||
|
||||
if (text == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
super.setText(text, BufferType.SPANNABLE);
|
||||
|
||||
previousText = text;
|
||||
previousBufferType = type;
|
||||
previousOverflowText = overflowText;
|
||||
useSystemEmoji = useSystemEmoji();
|
||||
previousTransformationMethod = getTransformationMethod();
|
||||
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
// We ellipsize them ourselves by manually truncating the appropriate section.
|
||||
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
|
||||
if (getMaxLines() > 0 && getMaxLines() != Integer.MAX_VALUE) {
|
||||
ellipsizeEmojiTextForMaxLines();
|
||||
} else if (maxLength > 0) {
|
||||
ellipsizeAnyTextForMaxLength();
|
||||
}
|
||||
}
|
||||
|
||||
if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
requestLayout();
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable CharSequence getTextToSet(@Nullable CharSequence text, BufferType type) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
|
||||
|
||||
if (scaleEmojis &&
|
||||
@@ -187,15 +307,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
if (unchanged(text, overflowText, type)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
previousText = text;
|
||||
previousOverflowText = overflowText;
|
||||
previousBufferType = type;
|
||||
useSystemEmoji = useSystemEmoji();
|
||||
previousTransformationMethod = getTransformationMethod();
|
||||
|
||||
Spannable textToSet;
|
||||
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
|
||||
textToSet = new SpannableStringBuilder(Optional.ofNullable(text).orElse(""));
|
||||
@@ -203,21 +317,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
|
||||
}
|
||||
|
||||
super.setText(textToSet, BufferType.SPANNABLE);
|
||||
|
||||
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
|
||||
// We ellipsize them ourselves by manually truncating the appropriate section.
|
||||
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
|
||||
if (getMaxLines() > 0 && getMaxLines() != Integer.MAX_VALUE) {
|
||||
ellipsizeEmojiTextForMaxLines();
|
||||
} else if (maxLength > 0) {
|
||||
ellipsizeAnyTextForMaxLength();
|
||||
}
|
||||
}
|
||||
|
||||
if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
requestLayout();
|
||||
}
|
||||
return textToSet;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,12 +388,15 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
CharSequence text = getText();
|
||||
if (text != null) {
|
||||
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
|
||||
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
|
||||
if (widthSpecMode != MeasureSpec.AT_MOST) {
|
||||
return widthMeasureSpec;
|
||||
}
|
||||
|
||||
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
|
||||
float measuredTextWidth = hasMetricAffectingSpan(text) ? Layout.getDesiredWidth(text, getPaint()) : getLongestLineWidth(text);
|
||||
int desiredWidth = (int) measuredTextWidth + getPaddingLeft() + getPaddingRight();
|
||||
|
||||
if (widthSpecMode == MeasureSpec.AT_MOST && desiredWidth < widthSpecSize) {
|
||||
if (desiredWidth < widthSpecSize) {
|
||||
return MeasureSpec.makeMeasureSpec(desiredWidth + 3, MeasureSpec.EXACTLY);
|
||||
}
|
||||
}
|
||||
@@ -338,7 +441,8 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
public void setOverflowText(@Nullable CharSequence overflowText) {
|
||||
this.overflowText = overflowText;
|
||||
setText(previousText, BufferType.SPANNABLE);
|
||||
this.requestedType = BufferType.SPANNABLE;
|
||||
resetText();
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
@@ -472,8 +576,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
if (!sizeChangeInProgress) {
|
||||
sizeChangeInProgress = true;
|
||||
setText(previousText, previousBufferType);
|
||||
sizeChangeInProgress = false;
|
||||
resetText();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -290,68 +292,70 @@ private fun AppSettingsContent(
|
||||
BackupFailureState.NONE -> Unit
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.AccountSettingsFragment__account),
|
||||
icon = painterResource(R.drawable.symbol_person_circle_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.AccountSettingsFragment__account),
|
||||
icon = painterResource(R.drawable.symbol_person_circle_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__linked_devices),
|
||||
icon = painterResource(R.drawable.symbol_devices_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.preferences__linked_devices),
|
||||
icon = painterResource(R.drawable.symbol_devices_24),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val context = LocalContext.current
|
||||
val donateUrl = stringResource(R.string.donate_url)
|
||||
item {
|
||||
val context = LocalContext.current
|
||||
val donateUrl = stringResource(R.string.donate_url)
|
||||
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.preferences__donate_to_signal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (state.hasExpiredGiftBadge) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_info_fill_24),
|
||||
tint = colorResource(R.color.signal_accent_primary),
|
||||
contentDescription = null
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.preferences__donate_to_signal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_heart_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (state.allowUserToGoToDonationManagementScreen) {
|
||||
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
|
||||
} else {
|
||||
CommunicationActions.openBrowserLink(context, donateUrl)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyDonorBadgeSubscriberIdToClipboard()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
if (state.hasExpiredGiftBadge) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_info_fill_24),
|
||||
tint = colorResource(R.color.signal_accent_primary),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_heart_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (state.allowUserToGoToDonationManagementScreen) {
|
||||
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
|
||||
} else {
|
||||
CommunicationActions.openBrowserLink(context, donateUrl)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyDonorBadgeSubscriberIdToClipboard()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -408,29 +412,31 @@ private fun AppSettingsContent(
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
TextWithBetaLabel(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
TextWithBetaLabel(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
|
||||
},
|
||||
enabled = isRegisteredAndUpToDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -455,7 +461,7 @@ private fun AppSettingsContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showPayments) {
|
||||
if (state.isPrimaryDevice && state.showPayments) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
@@ -630,7 +636,10 @@ private fun BioRow(
|
||||
|
||||
Text(
|
||||
text = prettyPhoneNumber,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = TextStyle(
|
||||
textDirection = TextDirection.ContentOrLtr
|
||||
)
|
||||
)
|
||||
|
||||
if (hasUsername) {
|
||||
@@ -692,6 +701,7 @@ private fun AppSettingsContentPreview() {
|
||||
)
|
||||
),
|
||||
state = AppSettingsState(
|
||||
isPrimaryDevice = true,
|
||||
unreadPaymentsCount = 5,
|
||||
hasExpiredGiftBadge = true,
|
||||
allowUserToGoToDonationManagementScreen = true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
|
||||
@Immutable
|
||||
data class AppSettingsState(
|
||||
val isPrimaryDevice: Boolean,
|
||||
val unreadPaymentsCount: Int,
|
||||
val hasExpiredGiftBadge: Boolean,
|
||||
val allowUserToGoToDonationManagementScreen: Boolean,
|
||||
|
||||
@@ -21,6 +21,7 @@ class AppSettingsViewModel : ViewModel() {
|
||||
|
||||
private val store = Store(
|
||||
AppSettingsState(
|
||||
isPrimaryDevice = SignalStore.account.isPrimaryDevice,
|
||||
unreadPaymentsCount = 0,
|
||||
hasExpiredGiftBadge = SignalStore.inAppPayments.getExpiredGiftBadge() != null,
|
||||
allowUserToGoToDonationManagementScreen = SignalStore.inAppPayments.isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),
|
||||
|
||||
@@ -21,7 +21,7 @@ class BioRecipientState(
|
||||
val username: String = recipient.username.orElse("")
|
||||
val featuredBadge: Badge? = recipient.featuredBadge
|
||||
val profileName: ProfileName = recipient.profileName
|
||||
val e164: String = recipient.requireE164()
|
||||
val e164: String = recipient.e164.orElse("")
|
||||
val combinedAboutAndEmoji: String? = recipient.combinedAboutAndEmoji
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user