mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-16 15:03:17 +01:00
Compare commits
217 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5f85c0661 | ||
|
|
91458f2702 | ||
|
|
6650ffc2c6 | ||
|
|
ab0102a372 | ||
|
|
a797bbf850 | ||
|
|
3804890265 | ||
|
|
fcdbf93626 | ||
|
|
f1b61f8f7e | ||
|
|
ce582249ec | ||
|
|
b21a72153a | ||
|
|
2a8bd20bb0 | ||
|
|
c30e3cc1b7 | ||
|
|
5fedd81921 | ||
|
|
24069dc42e | ||
|
|
ff15c8417a | ||
|
|
cbf770d3ea | ||
|
|
676ab1ab6f | ||
|
|
9cc47942f2 | ||
|
|
45e6e06c01 | ||
|
|
d2243707b5 | ||
|
|
48cd1c1da0 | ||
|
|
330a5aece2 | ||
|
|
8c4f614d17 | ||
|
|
f40bcb73fa | ||
|
|
905a6f1a6b | ||
|
|
8f78471849 | ||
|
|
82df20190d | ||
|
|
7f6e96a522 | ||
|
|
eded335766 | ||
|
|
7e4736969c | ||
|
|
78940ffc17 | ||
|
|
086883e565 | ||
|
|
e9cdf0368e | ||
|
|
7be273f461 | ||
|
|
e6cbb0073c | ||
|
|
469421fcf3 | ||
|
|
6d6d277277 | ||
|
|
8a5faba985 | ||
|
|
7aadc208e1 | ||
|
|
3c68e29679 | ||
|
|
4756b8d70b | ||
|
|
c2d927029a | ||
|
|
629b96dd20 | ||
|
|
01705459cf | ||
|
|
c449f72786 | ||
|
|
773d6c36dc | ||
|
|
b4bfb67a44 | ||
|
|
3165c854df | ||
|
|
f5cb1b0efa | ||
|
|
179908fba6 | ||
|
|
d6ec4bfbd3 | ||
|
|
237ac9f94a | ||
|
|
66f69854cf | ||
|
|
8f47592fc0 | ||
|
|
3ea7bf77e0 | ||
|
|
2b67b1c44f | ||
|
|
ebccc6db30 | ||
|
|
98d9b12438 | ||
|
|
5db8463c70 | ||
|
|
813252989b | ||
|
|
0319adbce4 | ||
|
|
de584ccb7d | ||
|
|
bd89c7fc39 | ||
|
|
bef4bb40ca | ||
|
|
b57d922cdf | ||
|
|
8c1cc03c6f | ||
|
|
f0109f3e6b | ||
|
|
ed89f3a78e | ||
|
|
faa6a1d3f0 | ||
|
|
969635d942 | ||
|
|
7665ae1464 | ||
|
|
9c18e3698e | ||
|
|
df406633ff | ||
|
|
d121f9402b | ||
|
|
5310c19b99 | ||
|
|
cd92feb2b7 | ||
|
|
3b603f08ed | ||
|
|
281f062b29 | ||
|
|
b054a7eb76 | ||
|
|
33b9c88ecd | ||
|
|
253d36ae13 | ||
|
|
8306f8ec5b | ||
|
|
69b6d7ef9a | ||
|
|
aeeba3d2df | ||
|
|
dfd2f7baf9 | ||
|
|
5de17a971d | ||
|
|
001896d244 | ||
|
|
1844b128e1 | ||
|
|
08623cc0c4 | ||
|
|
f93a948169 | ||
|
|
76476191be | ||
|
|
d00bb28ee4 | ||
|
|
453e5bede7 | ||
|
|
c7c108bd77 | ||
|
|
fb81574d35 | ||
|
|
e6d3de091c | ||
|
|
99b8a6020d | ||
|
|
88b21b6113 | ||
|
|
256ee9b1aa | ||
|
|
e2feaaf74c | ||
|
|
17def87c17 | ||
|
|
d90e9919ae | ||
|
|
38baf17938 | ||
|
|
3f7707985f | ||
|
|
a61072b249 | ||
|
|
80ff64ddd3 | ||
|
|
95c0467bda | ||
|
|
ff88d259fd | ||
|
|
6e747019d4 | ||
|
|
9e7a40a63d | ||
|
|
38eed43046 | ||
|
|
4c76cb682e | ||
|
|
c47adb7482 | ||
|
|
3c2ccef9a8 | ||
|
|
fb0c4757f2 | ||
|
|
b8b9a632b5 | ||
|
|
9b4a13a491 | ||
|
|
1cdd49721d | ||
|
|
8b895738c0 | ||
|
|
6ab3cd3390 | ||
|
|
11c8a726ec | ||
|
|
264447a6d9 | ||
|
|
a7bb2831f8 | ||
|
|
e05586a1c9 | ||
|
|
0e8dedf4d0 | ||
|
|
0e11a1fe3e | ||
|
|
f1ebd2dc81 | ||
|
|
8ea90c8a43 | ||
|
|
6456dcf657 | ||
|
|
bb151c91e9 | ||
|
|
ce6f39ae68 | ||
|
|
58e8ea08c2 | ||
|
|
4dd74d9ab4 | ||
|
|
3ef3a516b3 | ||
|
|
518a81c7fa | ||
|
|
f81325e7ca | ||
|
|
cc847cb229 | ||
|
|
7320a0ef46 | ||
|
|
7c45686440 | ||
|
|
8b5b83e974 | ||
|
|
a4a3861398 | ||
|
|
01bdaaea84 | ||
|
|
1f02fba696 | ||
|
|
aeb9054a63 | ||
|
|
bb33945a93 | ||
|
|
3d2ceef47f | ||
|
|
892e6bd853 | ||
|
|
78e1a407a6 | ||
|
|
48d766ecff | ||
|
|
d6d3226fcd | ||
|
|
ed4944f806 | ||
|
|
eb2dfb3fb6 | ||
|
|
265f71dff3 | ||
|
|
01d1769e4c | ||
|
|
97d099c7f1 | ||
|
|
0a957bc97c | ||
|
|
5df7552506 | ||
|
|
75334abe0f | ||
|
|
8524d20de5 | ||
|
|
495e2e043e | ||
|
|
dec9eb613e | ||
|
|
d6e7030dd0 | ||
|
|
6e43e931b2 | ||
|
|
430a55f89f | ||
|
|
d717aad03d | ||
|
|
efd86ad2fc | ||
|
|
b284835545 | ||
|
|
4dd30f4ec3 | ||
|
|
a48938f3d8 | ||
|
|
01989ad6e7 | ||
|
|
f37f67c6c0 | ||
|
|
36f7c60a99 | ||
|
|
3f067654d9 | ||
|
|
0ce3eab3cd | ||
|
|
b0f7c36cc2 | ||
|
|
966e208be5 | ||
|
|
a80d353e04 | ||
|
|
080fa88bfb | ||
|
|
172e3d129e | ||
|
|
52d5947c0a | ||
|
|
7334ebfce1 | ||
|
|
2c98bbaf7e | ||
|
|
5a91dba56e | ||
|
|
535c5a1574 | ||
|
|
b61c54c0e2 | ||
|
|
5ac5d45fc6 | ||
|
|
79ba929e70 | ||
|
|
3e9146a6f5 | ||
|
|
0c4c280a50 | ||
|
|
ebea499a5a | ||
|
|
d6b39e9f0a | ||
|
|
787eaee6a0 | ||
|
|
5ecb3d8832 | ||
|
|
b2e8666c9f | ||
|
|
8af41e4b2c | ||
|
|
5eaf1000c8 | ||
|
|
4ed6773983 | ||
|
|
0de0441f65 | ||
|
|
9e1b4a9a8c | ||
|
|
bf28b90e89 | ||
|
|
a0a962a94f | ||
|
|
abe0b2ebca | ||
|
|
7b4fe7ff40 | ||
|
|
1ba9793943 | ||
|
|
14d4228e86 | ||
|
|
3d2c51c14b | ||
|
|
72d75e9cd5 | ||
|
|
e125fa6bfb | ||
|
|
57574126bb | ||
|
|
833c81a99e | ||
|
|
5ca17dfe52 | ||
|
|
5e058bb655 | ||
|
|
ce87b50a07 | ||
|
|
2ad14800d1 | ||
|
|
f04a0533cb | ||
|
|
5ae51f844e | ||
|
|
4ce2c6ef73 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -17,7 +17,7 @@ body:
|
||||
label: "Guidelines"
|
||||
description: "Search issues here: https://github.com/signalapp/Signal-Android/issues/?q=is%3Aissue+"
|
||||
options:
|
||||
- label: I have searched searched open and closed issues for duplicates
|
||||
- label: I have searched open and closed issues for duplicates
|
||||
required: true
|
||||
- label: I am submitting a bug report for existing functionality that does not work as intended
|
||||
required: true
|
||||
|
||||
23
.github/stale.yml
vendored
23
.github/stale.yml
vendored
@@ -1,23 +0,0 @@
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
|
||||
issues:
|
||||
exemptLabels:
|
||||
- acknowledged
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
closeComment: >
|
||||
This issue has been closed due to inactivity.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 1
|
||||
37
.github/workflows/stale.yml
vendored
Normal file
37
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Mark stale issues and PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # daily at 02:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
exempt-issue-labels: 'acknowledged'
|
||||
exempt-pr-labels: 'acknowledged'
|
||||
stale-issue-label: 'wontfix'
|
||||
stale-pr-label: 'wontfix'
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
close-issue-message: >
|
||||
This issue has been closed due to inactivity.
|
||||
close-pr-message: >
|
||||
This pull request has been closed due to inactivity.
|
||||
operations-per-run: 150
|
||||
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1672
|
||||
val canonicalVersionName = "8.5.1"
|
||||
val canonicalVersionCode = 1680
|
||||
val canonicalVersionName = "8.8.0"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -692,7 +692,6 @@ dependencies {
|
||||
implementation(libs.android.tooltips) {
|
||||
exclude(group = "com.android.support", module = "appcompat-v7")
|
||||
}
|
||||
implementation(libs.stream)
|
||||
implementation(libs.lottie)
|
||||
implementation(libs.lottie.compose)
|
||||
implementation(libs.signal.android.database.sqlcipher)
|
||||
|
||||
@@ -26454,61 +26454,6 @@
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor mmsCursor = db.query("mms", new String[] {"_id"},"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="298"
|
||||
column="38"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor partCursor = db.query("part", new String[] {"_id", "ct", "_data", "encrypted"},"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="310"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor threadCursor = db.query("thread", new String[] {"_id"}, null, null, null, null, null);"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="708"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor cursor = db.rawQuery("SELECT DISTINCT date AS date_received, status, " +"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="713"
|
||||
column="28"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" cursor = db.query("mms", new String[] {"_id", "network_failures"}, "network_failures IS NOT NULL", null, null, null, null);"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="1037"
|
||||
column="19"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ObsoleteSdkInt"
|
||||
message="Unnecessary; SDK_INT is always >= 21"
|
||||
|
||||
@@ -359,8 +359,8 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) = Unit
|
||||
|
||||
override fun onExpandEvents(messageId: Long) = Unit
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
|
||||
override fun onCollapseEvents(messageId: Long) = Unit
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package org.thoughtcrime.securesms.database
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -298,4 +300,24 @@ class CollapsingMessagesTests {
|
||||
assertEquals(CollapsedState.COLLAPSED, msgCall4.collapsedState)
|
||||
assertEquals(call3.messageId, msgCall4.collapsedHeadId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMaxCollapsedSet_whenIAddAnotherEvent_thenIExpectANewHead() {
|
||||
mockkObject(CollapsibleEvents)
|
||||
every { CollapsibleEvents.MAX_SIZE } returns 2
|
||||
|
||||
val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId
|
||||
val messageId2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false).messageId
|
||||
val messageId3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false).messageId
|
||||
|
||||
val msg1 = message.getMessageRecord(messageId1)
|
||||
val msg2 = message.getMessageRecord(messageId2)
|
||||
val msg3 = message.getMessageRecord(messageId3)
|
||||
|
||||
assertEquals(CollapsedState.HEAD_COLLAPSED, msg1.collapsedState)
|
||||
assertEquals(CollapsedState.PENDING_COLLAPSED, msg2.collapsedState)
|
||||
assertEquals(CollapsedState.HEAD_COLLAPSED, msg3.collapsedState)
|
||||
assertEquals(messageId3, msg3.collapsedHeadId)
|
||||
unmockkObject(CollapsibleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -35,8 +34,6 @@ class ThreadTableTest_active {
|
||||
fun setUp() {
|
||||
mockkStatic(RemoteConfig::class)
|
||||
|
||||
every { RemoteConfig.showChatFolders } returns true
|
||||
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -30,8 +29,6 @@ class ThreadTableTest_pinned {
|
||||
fun setUp() {
|
||||
mockkStatic(RemoteConfig::class)
|
||||
|
||||
every { RemoteConfig.showChatFolders } returns true
|
||||
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,24 +29,39 @@ class ThreadTableTest_recents {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARecentRecipient_whenIBlockAndGetRecents_thenIDoNotExpectToSeeThatRecipient() {
|
||||
// GIVEN
|
||||
fun getRecentConversationList_excludes_blocked_recipients() {
|
||||
createActiveThreadFor(recipient)
|
||||
|
||||
SignalDatabase.recipients.setBlocked(recipient.id, true)
|
||||
|
||||
assertFalse(recipient.id in getRecentConversationRecipients(limit = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecentConversationList_excludes_hidden_recipients() {
|
||||
createActiveThreadFor(recipient)
|
||||
|
||||
SignalDatabase.recipients.markHidden(recipient.id)
|
||||
|
||||
assertFalse(recipient.id in getRecentConversationRecipients(limit = 10))
|
||||
}
|
||||
|
||||
private fun createActiveThreadFor(recipient: Recipient) {
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.recipients.setBlocked(recipient.id, true)
|
||||
val results: MutableList<RecipientId> = SignalDatabase.threads.getRecentConversationList(10, false, false, false, false, false, false).use { cursor ->
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)))
|
||||
@Suppress("SameParameterValue")
|
||||
private fun getRecentConversationRecipients(limit: Int = 10): Set<RecipientId> {
|
||||
return SignalDatabase.threads
|
||||
.getRecentConversationList(limit = limit, includeInactiveGroups = false, individualsOnly = false, groupsOnly = false, hideV1Groups = false, hideSms = false, hideSelf = false)
|
||||
.use { cursor ->
|
||||
buildSet {
|
||||
while (cursor.moveToNext()) {
|
||||
add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
// THEN
|
||||
assertFalse(recipient.id in results)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ class DataMessageProcessorTest_polls {
|
||||
|
||||
private fun insertPoll(allowMultiple: Boolean = true): Long {
|
||||
val envelope = MessageContentFuzzer.envelope(100)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.clientTimestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
|
||||
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
|
||||
return messageId.messageId
|
||||
|
||||
@@ -43,7 +43,7 @@ object MessageContentFuzzer {
|
||||
*/
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
|
||||
return Envelope.Builder()
|
||||
.timestamp(timestamp)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.build()
|
||||
@@ -292,7 +292,7 @@ object MessageContentFuzzer {
|
||||
body = string()
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = quoted.envelope.timestamp
|
||||
id = quoted.envelope.clientTimestamp
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
|
||||
@@ -304,7 +304,7 @@ object MessageContentFuzzer {
|
||||
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
|
||||
id = random.nextLong(quoted.envelope.clientTimestamp!! - 1000000, quoted.envelope.clientTimestamp!!)
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
}
|
||||
@@ -333,7 +333,7 @@ object MessageContentFuzzer {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorAciBinary = reactTo.metadata.sourceServiceId.toByteString()
|
||||
targetSentTimestamp = reactTo.envelope.timestamp
|
||||
targetSentTimestamp = reactTo.envelope.clientTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ object MockProvider {
|
||||
val device = PreKeyResponseItem().apply {
|
||||
this.deviceId = deviceId
|
||||
registrationId = KeyHelper.generateRegistrationId(false)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
|
||||
}
|
||||
|
||||
return PreKeyResponse().apply {
|
||||
|
||||
@@ -212,7 +212,7 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.serverTimestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ object Generator {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.sourceDeviceId(1)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.destinationServiceIdBinary(destination.toByteString())
|
||||
|
||||
@@ -354,11 +354,11 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onExpandEvents(messageId: Long) {
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onCollapseEvents(messageId: Long) {
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +482,7 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name="org.signal.mediasend.MediaSendActivity"
|
||||
android:name=".mediasend.v3.MediaSendV3Activity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
|
||||
import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
@@ -102,12 +103,14 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
@@ -214,7 +217,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addNonBlocking(this::ensureProfileUploaded)
|
||||
.addNonBlocking(() -> AppDependencies.getExpireStoriesManager().scheduleIfNecessary())
|
||||
.addNonBlocking(BackupRepository::maybeFixAnyDanglingUploadProgress)
|
||||
.addNonBlocking(BackupRepository::maybeFixAnyDanglingLocalExportProgress)
|
||||
.addPostRender(() -> AppDependencies.getDeletedCallEventManager().scheduleIfNecessary())
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
@@ -227,6 +229,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
|
||||
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||
.addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob()))
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
|
||||
@@ -401,6 +404,20 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
AppDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
}
|
||||
AppForegroundObserver.begin();
|
||||
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
initializeRegistrationDependencies();
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeRegistrationDependencies() {
|
||||
org.signal.registration.RegistrationDependencies.Companion.provide(
|
||||
new org.signal.registration.RegistrationDependencies(
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private void initializeFirstEverAppLaunch() {
|
||||
|
||||
@@ -148,7 +148,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onViewPollClicked(long messageId);
|
||||
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
|
||||
void onViewPinnedMessage(long messageId);
|
||||
void onExpandEvents(long messageId);
|
||||
void onCollapseEvents(long messageId);
|
||||
void onExpandEvents(long messageId, @NonNull View itemView, int collapsedSize);
|
||||
void onCollapseEvents(long messageId, @NonNull View itemView, int collapsedSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -340,7 +341,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
this,
|
||||
currentSelection.stream()
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
.collect(Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
@@ -365,9 +366,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
if (onRefreshListener != null) {
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
contactSearchMediator.refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -469,7 +468,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return contactSearchMediator.getSelectedContacts()
|
||||
.stream()
|
||||
.map(ContactSearchKey::requireSelectedContact)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public int getSelectedContactsCount() {
|
||||
@@ -664,7 +663,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
.filter(r -> !contactSearchMediator.getSelectedContacts()
|
||||
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
|
||||
.map(SelectedContact::forRecipientId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (toMarkSelected.isEmpty()) {
|
||||
return;
|
||||
|
||||
@@ -96,6 +96,8 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
@@ -159,8 +161,9 @@ import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
@@ -269,7 +272,7 @@ class MainActivity :
|
||||
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
|
||||
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
|
||||
@@ -296,7 +299,7 @@ class MainActivity :
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
navigator = MainNavigator(this, mainNavigationViewModel)
|
||||
|
||||
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
|
||||
mediaSendLauncher = mediaSendLauncher()
|
||||
|
||||
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
|
||||
override fun onForeground() {
|
||||
@@ -342,6 +345,19 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
ArchiveRestoreProgress
|
||||
.stateFlow
|
||||
.filter { it.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE }
|
||||
.collect {
|
||||
ArchiveRestoreProgress.clearLocalRestoreDirectoryError()
|
||||
CouldNotCompleteBackupRestoreSheet().show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
Log.i(TAG, "Local restore directory became unavailable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supportFragmentManager.setFragmentResultListener(
|
||||
@@ -1109,7 +1125,7 @@ class MainActivity :
|
||||
if (isForQuickRestore) {
|
||||
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
|
||||
} else if (SignalStore.internal.useNewMediaActivity) {
|
||||
mediaActivityLauncher.launch(
|
||||
mediaSendLauncher.launch(
|
||||
MediaSendActivityContract.Args(
|
||||
isCameraFirst = false,
|
||||
isStory = destination == MainNavigationListLocation.STORIES
|
||||
@@ -1125,7 +1141,7 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
if (CameraXUtil.isSupported()) {
|
||||
if (CameraXRemoteConfig.isSupported()) {
|
||||
onGranted()
|
||||
} else {
|
||||
Permissions.with(this@MainActivity)
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
Intent intent = getIntentForState(applicationState);
|
||||
if (intent != null) {
|
||||
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
|
||||
startActivity(intent);
|
||||
finish();
|
||||
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
|
||||
startActivity(intent);
|
||||
} else {
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +178,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_ENTER_SIGNAL_PIN;
|
||||
} else if (userMustSetProfileName()) {
|
||||
return STATE_CREATE_PROFILE_NAME;
|
||||
} else if (userMustCreateSignalPin()) {
|
||||
} else if (userMustCreateSignalPin() && getClass() != CreateSvrPinActivity.class) {
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
|
||||
return STATE_TRANSFER_ONGOING;
|
||||
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getPushRegistrationIntent() {
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this);
|
||||
} else {
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
}
|
||||
|
||||
private Intent getEnterSignalPinIntent() {
|
||||
|
||||
@@ -19,7 +19,7 @@ package org.thoughtcrime.securesms;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
@@ -58,7 +58,7 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
|
||||
protected final void onFinishedSelection() {
|
||||
Intent resultIntent = getIntent();
|
||||
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId()).toList();
|
||||
List<RecipientId> recipients = selectedContacts.stream().map(sc -> sc.getOrCreateRecipientId()).collect(Collectors.toList());
|
||||
|
||||
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -53,6 +54,13 @@ object ApkUpdateInstaller {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDownloadSuccessful(context, downloadId)) {
|
||||
Log.w(TAG, "DownloadId matches, but the download was not successful. The download may have failed due to a network issue. Clearing state and re-checking for updates.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
AppDependencies.jobManager.add(ApkUpdateJob())
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
@@ -91,6 +99,8 @@ object ApkUpdateInstaller {
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setAppPackageName(context.packageName)
|
||||
|
||||
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
|
||||
// This lets us skip the system-generated notification.
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
@@ -134,6 +144,35 @@ object ApkUpdateInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = context.getDownloadManager().query(query)
|
||||
|
||||
return cursor.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val status = cursor
|
||||
.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
.takeUnless { it == -1 }
|
||||
?.let { cursor.getInt(it) } ?: DownloadManager.STATUS_FAILED
|
||||
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
return@use true
|
||||
}
|
||||
|
||||
val reason = cursor
|
||||
.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
.takeUnless { it == -1 }
|
||||
?.let { cursor.getInt(it) }
|
||||
|
||||
Log.w(TAG, "Download not successful. Status: $status, Reason: $reason")
|
||||
false
|
||||
} else {
|
||||
Log.w(TAG, "Download ID $downloadId not found in DownloadManager.")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
|
||||
return try {
|
||||
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
|
||||
|
||||
@@ -8,16 +8,19 @@ package org.thoughtcrime.securesms.attachments
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import org.signal.blurhash.BlurHashEncoder
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,29 @@ object AttachmentUploadUtil {
|
||||
*/
|
||||
val FOREGROUND_LIMIT_BYTES: Long = 10.mebiBytes.inWholeBytes
|
||||
|
||||
/**
|
||||
* Computes the base64-encoded SHA-256 checksum of the ciphertext that would result from encrypting [plaintextStream]
|
||||
* with the given [key] and [iv], including padding, IV prefix, and HMAC suffix.
|
||||
*/
|
||||
fun computeCiphertextChecksum(key: ByteArray, iv: ByteArray, plaintextStream: InputStream, plaintextSize: Long): String {
|
||||
val paddedStream = PaddingInputStream(plaintextStream, plaintextSize)
|
||||
return Base64.encodeWithPadding(AttachmentCipherStreamUtil.computeCiphertextSha256(key, iv, paddedStream))
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the base64-encoded SHA-256 checksum of the raw bytes in [inputStream].
|
||||
* Used for pre-encrypted uploads where the data is already in its final form.
|
||||
*/
|
||||
fun computeRawChecksum(inputStream: InputStream): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
return Base64.encodeWithPadding(digest.digest())
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [SignalServiceAttachmentStream] from the provided data, which can then be provided to various upload methods.
|
||||
*/
|
||||
@@ -39,7 +65,6 @@ object AttachmentUploadUtil {
|
||||
fun buildSignalServiceAttachmentStream(
|
||||
context: Context,
|
||||
attachment: Attachment,
|
||||
uploadSpec: ResumableUpload,
|
||||
cancellationSignal: (() -> Boolean)? = null,
|
||||
progressListener: ProgressListener? = null
|
||||
): SignalServiceAttachmentStream {
|
||||
@@ -57,7 +82,6 @@ object AttachmentUploadUtil {
|
||||
.withHeight(attachment.height)
|
||||
.withUploadTimestamp(System.currentTimeMillis())
|
||||
.withCaption(attachment.caption)
|
||||
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
|
||||
.withCancelationSignal(cancellationSignal)
|
||||
.withListener(progressListener)
|
||||
.withUuid(attachment.uuid)
|
||||
|
||||
@@ -145,6 +145,11 @@ class PointerAttachment : Attachment {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
|
||||
if (cdn == Cdn.S3) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
quote = true,
|
||||
@@ -153,7 +158,7 @@ class PointerAttachment : Attachment {
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
|
||||
fileName = quotedAttachment.fileName,
|
||||
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
|
||||
cdn = cdn,
|
||||
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
|
||||
key = thumbnail?.asPointer()?.key?.let { Base64.encodeWithPadding(it) },
|
||||
iv = null,
|
||||
|
||||
@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -223,7 +223,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun openCameraCapture() {
|
||||
if (CameraXUtil.isSupported()) {
|
||||
if (CameraXRemoteConfig.isSupported()) {
|
||||
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
|
||||
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ package org.thoughtcrime.securesms.backup
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
val LocalBackupCreationProgress.isIdle: Boolean
|
||||
get() = idle != null || (exporting == null && transferring == null && canceled == null)
|
||||
get() = idle != null || succeeded != null || failed != null || canceled != null || (exporting == null && transferring == null)
|
||||
|
||||
fun LocalBackupCreationProgress.exportProgress(): Float {
|
||||
val exporting = exporting ?: return 0f
|
||||
|
||||
@@ -11,8 +11,6 @@ import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import com.annimon.stream.function.Predicate;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
@@ -71,6 +69,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
object LocalExportProgress {
|
||||
val internalEncryptedProgress = MutableStateFlow(LocalBackupCreationProgress())
|
||||
val internalPlaintextProgress = MutableStateFlow(LocalBackupCreationProgress())
|
||||
|
||||
val encryptedProgress: StateFlow<LocalBackupCreationProgress> = internalEncryptedProgress
|
||||
val plaintextProgress: StateFlow<LocalBackupCreationProgress> = internalPlaintextProgress
|
||||
|
||||
fun setEncryptedProgress(progress: LocalBackupCreationProgress) {
|
||||
internalEncryptedProgress.value = progress
|
||||
}
|
||||
|
||||
fun setPlaintextProgress(progress: LocalBackupCreationProgress) {
|
||||
internalPlaintextProgress.value = progress
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,10 @@ object ExportSkips {
|
||||
return log(sentTimestamp, "Invalid e164 in sessions switchover event. Exporting an empty event.")
|
||||
}
|
||||
|
||||
fun donationRequestNotInReleaseNotesChat(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Donation request not in Release Notes chat.")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[SKIP][$sentTimestamp] $message"
|
||||
}
|
||||
@@ -194,6 +198,10 @@ object ExportOddities {
|
||||
return log(sentTimestamp, "Revisions for this message contained items of a different type than the parent item. Ignoring mismatched revisions.")
|
||||
}
|
||||
|
||||
fun mismatchedRevisionAuthor(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Revisions for this message contained items with a different author than the parent item. Ignoring mismatched revisions.")
|
||||
}
|
||||
|
||||
fun outgoingMessageWasSentButTimerNotStarted(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Outgoing expiring message was sent, but the timer wasn't started. Setting expireStartDate to dateReceived.")
|
||||
}
|
||||
|
||||
@@ -157,6 +157,11 @@ object ArchiveRestoreProgress {
|
||||
update()
|
||||
}
|
||||
|
||||
fun clearLocalRestoreDirectoryError() {
|
||||
SignalStore.backup.localRestoreDirectoryError = false
|
||||
update()
|
||||
}
|
||||
|
||||
fun clearFinishedStatus() {
|
||||
store.update { state ->
|
||||
if (state.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.FINISHED) {
|
||||
@@ -193,7 +198,11 @@ object ArchiveRestoreProgress {
|
||||
!NetworkConstraint.isMet(AppDependencies.application) -> ArchiveRestoreProgressState.RestoreStatus.WAITING_FOR_INTERNET
|
||||
!BatteryNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.LOW_BATTERY
|
||||
!DiskSpaceNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.NOT_ENOUGH_DISK_SPACE
|
||||
restoreState == RestoreState.NONE -> if (state.hasActivelyRestoredThisRun) ArchiveRestoreProgressState.RestoreStatus.FINISHED else ArchiveRestoreProgressState.RestoreStatus.NONE
|
||||
restoreState == RestoreState.NONE -> when {
|
||||
SignalStore.backup.localRestoreDirectoryError -> ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE
|
||||
state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED
|
||||
else -> ArchiveRestoreProgressState.RestoreStatus.NONE
|
||||
}
|
||||
else -> {
|
||||
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ data class ArchiveRestoreProgressState(
|
||||
WAITING_FOR_INTERNET,
|
||||
WAITING_FOR_WIFI,
|
||||
NOT_ENOUGH_DISK_SPACE,
|
||||
FINISHED
|
||||
FINISHED,
|
||||
LOCAL_RESTORE_DIRECTORY_UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,11 @@ import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.exportForDebugging
|
||||
import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
|
||||
@@ -116,7 +114,6 @@ import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CancelRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalArchiveJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
@@ -133,7 +130,6 @@ import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
@@ -428,6 +424,12 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun markOutOfRemoteStorageSpaceError() {
|
||||
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
|
||||
return
|
||||
}
|
||||
|
||||
SignalStore.backup.markNotEnoughRemoteStorageSpace()
|
||||
|
||||
val context = AppDependencies.application
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
|
||||
@@ -440,8 +442,6 @@ object BackupRepository {
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification)
|
||||
|
||||
SignalStore.backup.markNotEnoughRemoteStorageSpace()
|
||||
}
|
||||
|
||||
fun clearOutOfRemoteStorageSpaceError() {
|
||||
@@ -593,14 +593,6 @@ object BackupRepository {
|
||||
SignalStore.backup.snoozeDownloadNotifier()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun maybeFixAnyDanglingLocalExportProgress() {
|
||||
if (!SignalStore.backup.newLocalBackupProgress.isIdle && AppDependencies.jobManager.find { it.factoryKey == LocalArchiveJob.KEY }.isEmpty()) {
|
||||
Log.w(TAG, "Found stale local backup progress with no active job. Resetting to idle.")
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun maybeFixAnyDanglingUploadProgress() {
|
||||
if (SignalStore.account.isLinkedDevice) {
|
||||
@@ -1649,6 +1641,13 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): NetworkResult<Unit> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
@@ -1688,7 +1687,6 @@ object BackupRepository {
|
||||
|
||||
/**
|
||||
* Retrieves an [AttachmentUploadForm] that can be used to upload an attachment to the transit cdn.
|
||||
* To continue the upload, use [org.whispersystems.signalservice.api.attachment.AttachmentApi.getResumableUploadSpec].
|
||||
*
|
||||
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
|
||||
*/
|
||||
@@ -1726,10 +1724,10 @@ object BackupRepository {
|
||||
/**
|
||||
* Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn.
|
||||
*/
|
||||
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
val request = buildArchiveMediaRequest(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
|
||||
SignalNetwork.archive.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -1746,7 +1744,7 @@ object BackupRepository {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val mediaName = attachment.requireMediaName()
|
||||
val request = attachment.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
|
||||
val request = buildArchiveMediaRequest(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, mediaName, credential.mediaBackupAccess.backupKey)
|
||||
SignalNetwork.archive
|
||||
.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -2197,15 +2195,15 @@ object BackupRepository {
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
|
||||
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
private fun buildArchiveMediaRequest(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName)
|
||||
|
||||
return ArchiveMediaRequest(
|
||||
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
|
||||
cdn = cdn.cdnNumber,
|
||||
key = remoteLocation!!
|
||||
cdn = cdnNumber,
|
||||
key = remoteLocation
|
||||
),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)).toInt(),
|
||||
mediaId = mediaSecrets.id.encode(),
|
||||
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
|
||||
encryptionKey = Base64.encodeWithPadding(mediaSecrets.aesKey)
|
||||
@@ -2618,3 +2616,9 @@ class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UploadedThumbnailInfo(
|
||||
val cdnNumber: Int,
|
||||
val remoteLocation: String,
|
||||
val size: Long
|
||||
)
|
||||
|
||||
@@ -241,6 +241,10 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
|
||||
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
|
||||
if (exportState.threadIdToRecipientId[builder.chatId] != exportState.releaseNoteRecipientId) {
|
||||
Log.w(TAG, ExportSkips.donationRequestNotInReleaseNotesChat(builder.dateSent))
|
||||
continue
|
||||
}
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
@@ -1741,19 +1745,24 @@ private fun ChatUpdateMessage.canOnlyBeAuthoredBySelf(): Boolean {
|
||||
}
|
||||
|
||||
private fun List<ChatItem>.repairRevisions(current: ChatItem.Builder): List<ChatItem> {
|
||||
val authorFiltered = this.filter { it.authorId == current.authorId }
|
||||
if (authorFiltered.size != this.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionAuthor(current.dateSent))
|
||||
}
|
||||
|
||||
return if (current.standardMessage != null) {
|
||||
val filtered = this
|
||||
val filtered = authorFiltered
|
||||
.filter { it.standardMessage != null }
|
||||
.map { it.withDowngradeVoiceNotes() }
|
||||
|
||||
if (this.size != filtered.size) {
|
||||
if (authorFiltered.size != filtered.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
|
||||
}
|
||||
|
||||
filtered
|
||||
} else if (current.directStoryReplyMessage != null) {
|
||||
val filtered = this.filter { it.directStoryReplyMessage != null }
|
||||
if (this.size != filtered.size) {
|
||||
val filtered = authorFiltered.filter { it.directStoryReplyMessage != null }
|
||||
if (authorFiltered.size != filtered.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
|
||||
}
|
||||
filtered
|
||||
|
||||
@@ -362,12 +362,13 @@ class ChatItemArchiveImporter(
|
||||
} else if (pinMessage != null) {
|
||||
followUps += { pinUpdateMessageId ->
|
||||
val targetAuthorId = importState.remoteToLocalRecipientId[pinMessage.authorId]
|
||||
if (targetAuthorId != null) {
|
||||
val targetAuthorAci = targetAuthorId?.let { recipients.getRecord(it).aci }
|
||||
if (targetAuthorId != null && targetAuthorAci != null) {
|
||||
val pinnedMessageId = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp, targetAuthorId)?.id ?: -1
|
||||
val messageExtras = MessageExtras(
|
||||
pinnedMessage = PinnedMessage(
|
||||
pinnedMessageId = pinnedMessageId,
|
||||
targetAuthorAci = recipients.getRecord(targetAuthorId).aci!!.toByteString(),
|
||||
targetAuthorAci = targetAuthorAci.toByteString(),
|
||||
targetTimestamp = pinMessage.targetSentTimestamp
|
||||
)
|
||||
)
|
||||
@@ -383,6 +384,8 @@ class ChatItemArchiveImporter(
|
||||
.where("${MessageTable.ID} = ?", pinnedMessageId)
|
||||
.run()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Pin message target author not found or has no ACI, skipping pin message extras.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,6 +475,7 @@ class ChatItemArchiveImporter(
|
||||
val ids = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, listOf(longTextAttachment), emptyList())
|
||||
ids.values.firstOrNull()?.let { attachmentId ->
|
||||
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,6 +515,7 @@ class ChatItemArchiveImporter(
|
||||
if (longTextAttachment != null) {
|
||||
attachmentMap[longTextAttachment]?.let { attachmentId ->
|
||||
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,7 +718,7 @@ class ChatItemArchiveImporter(
|
||||
when {
|
||||
itemStandardMessage != null -> contentValues.addStandardMessage(itemStandardMessage)
|
||||
itemRemoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
|
||||
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId)
|
||||
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId, chatRecipientId)
|
||||
itemPaymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
|
||||
itemGiftBadge != null -> contentValues.addGiftBadge(itemGiftBadge)
|
||||
itemViewOnceMessage != null -> contentValues.addViewOnce(itemViewOnceMessage)
|
||||
@@ -861,7 +866,7 @@ class ChatItemArchiveImporter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId) {
|
||||
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId, chatRecipientId: RecipientId) {
|
||||
var typeFlags: Long = 0
|
||||
val simpleUpdate = updateMessage.simpleUpdate
|
||||
val expirationTimerChange = updateMessage.expirationTimerChange
|
||||
@@ -902,6 +907,11 @@ class ChatItemArchiveImporter(
|
||||
put(MessageTable.FROM_RECIPIENT_ID, toRecipientId.serialize())
|
||||
put(MessageTable.TO_RECIPIENT_ID, fromRecipientId.serialize())
|
||||
}
|
||||
|
||||
// directionless 1:1 message requests expect to recipient to be the other recipient not self
|
||||
if (simpleUpdate.type == SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED) {
|
||||
put(MessageTable.TO_RECIPIENT_ID, chatRecipientId.serialize())
|
||||
}
|
||||
}
|
||||
expirationTimerChange != null -> {
|
||||
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.local
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.archive.local.ArchivedFilesWriter
|
||||
import org.signal.archive.local.proto.FilesFrame
|
||||
@@ -20,7 +22,9 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.core.util.toJson
|
||||
import org.signal.libsignal.crypto.Aes256Ctr32
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -33,11 +37,6 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Collections
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
typealias ArchiveResult = org.signal.core.util.Result<LocalArchiver.ArchiveSuccess, LocalArchiver.ArchiveFailure>
|
||||
typealias RestoreResult = org.signal.core.util.Result<LocalArchiver.RestoreSuccess, LocalArchiver.RestoreFailure>
|
||||
@@ -74,10 +73,10 @@ object LocalArchiver {
|
||||
|
||||
Log.i(TAG, "Listing all current files")
|
||||
val allFiles = filesFileSystem.allFiles { completed, total ->
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong()))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())))
|
||||
}
|
||||
stopwatch.split("files-list")
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING)))
|
||||
|
||||
val mediaNames: MutableSet<MediaName> = Collections.synchronizedSet(HashSet())
|
||||
|
||||
@@ -146,36 +145,44 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a plaintext archive to the provided [zipOutputStream].
|
||||
* Export a plaintext archive to the provided [directory].
|
||||
*/
|
||||
fun exportPlaintext(
|
||||
zipOutputStream: ZipOutputStream,
|
||||
directory: DocumentFile,
|
||||
contentResolver: ContentResolver,
|
||||
includeMedia: Boolean,
|
||||
stopwatch: Stopwatch,
|
||||
cancellationSignal: () -> Boolean = { false }
|
||||
): ArchiveResult {
|
||||
try {
|
||||
zipOutputStream.putNextEntry(ZipEntry("metadata.json"))
|
||||
zipOutputStream.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
|
||||
zipOutputStream.closeEntry()
|
||||
val metadataFile = directory.createFile("application/octet-stream", "metadata.json")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
|
||||
contentResolver.openOutputStream(metadataFile.uri)?.use { out ->
|
||||
out.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
|
||||
} ?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
|
||||
stopwatch.split("metadata")
|
||||
|
||||
zipOutputStream.putNextEntry(ZipEntry("main.jsonl"))
|
||||
val mainFile = directory.createFile("application/octet-stream", "main.jsonl")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.MainStream)
|
||||
val progressListener = LocalPlaintextExportProgressListener()
|
||||
val attachments = BackupRepository.exportForLocalPlaintextArchive(
|
||||
outputStream = zipOutputStream,
|
||||
progressEmitter = progressListener,
|
||||
cancellationSignal = cancellationSignal,
|
||||
includeMedia = includeMedia
|
||||
)
|
||||
zipOutputStream.closeEntry()
|
||||
val attachments = contentResolver.openOutputStream(mainFile.uri)?.use { mainStream ->
|
||||
BackupRepository.exportForLocalPlaintextArchive(
|
||||
outputStream = mainStream,
|
||||
progressEmitter = progressListener,
|
||||
cancellationSignal = cancellationSignal,
|
||||
includeMedia = includeMedia
|
||||
)
|
||||
} ?: return ArchiveResult.failure(ArchiveFailure.MainStream)
|
||||
stopwatch.split("frames")
|
||||
|
||||
if (includeMedia) {
|
||||
val filesDir = directory.createDirectory("files")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.FilesStream)
|
||||
val total = attachments.size.toLong()
|
||||
var completed = 0L
|
||||
progressListener.onAttachment(0, total)
|
||||
val writtenEntries = HashSet<String>()
|
||||
val prefixDirs = HashMap<String, DocumentFile>()
|
||||
for (attachment in attachments) {
|
||||
if (cancellationSignal()) break
|
||||
val mediaName = MediaName.forLocalBackupFilename(attachment.plaintextHash, attachment.localBackupKey.key)
|
||||
@@ -186,13 +193,21 @@ object LocalArchiver {
|
||||
?.let { ".$it" }
|
||||
?: ""
|
||||
val prefix = mediaName.name.substring(0..1)
|
||||
val entryName = "files/$prefix/${mediaName.name}$ext"
|
||||
val entryName = "$prefix/${mediaName.name}$ext"
|
||||
if (!writtenEntries.add(entryName)) continue
|
||||
zipOutputStream.putNextEntry(ZipEntry(entryName))
|
||||
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
|
||||
StreamUtil.copy(input, zipOutputStream, false, false)
|
||||
val prefixDir = prefixDirs[prefix]
|
||||
?: filesDir.createDirectory(prefix)?.also { prefixDirs[prefix] = it }
|
||||
?: run {
|
||||
Log.w(TAG, "Unable to create prefix directory $prefix, skipping attachment ${attachment.attachmentId}")
|
||||
progressListener.onAttachment(++completed, total)
|
||||
continue
|
||||
}
|
||||
val mediaFile = prefixDir.createFile("application/octet-stream", "${mediaName.name}$ext") ?: continue
|
||||
contentResolver.openOutputStream(mediaFile.uri)?.use { out ->
|
||||
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
|
||||
StreamUtil.copy(input, out, false, false)
|
||||
}
|
||||
}
|
||||
zipOutputStream.closeEntry()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to export attachment ${attachment.attachmentId}, skipping", e)
|
||||
}
|
||||
@@ -216,14 +231,19 @@ object LocalArchiver {
|
||||
val metadataKey = SignalStore.backup.messageBackupKey.deriveLocalBackupMetadataKey()
|
||||
val iv = Util.getSecretBytes(12)
|
||||
val backupId = SignalStore.backup.messageBackupKey.deriveBackupId(SignalStore.account.requireAci())
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
|
||||
val cipherText = cipher.doFinal(backupId.value)
|
||||
val cipherText = applyCipher(backupId.value, metadataKey, iv)
|
||||
|
||||
return Metadata.EncryptedBackupId(iv = iv.toByteString(), encryptedId = cipherText.toByteString())
|
||||
}
|
||||
|
||||
private fun applyCipher(input: ByteArray, metadataKey: ByteArray, iv: ByteArray): ByteArray {
|
||||
val data = input.copyOf()
|
||||
val cipher = Aes256Ctr32(metadataKey, iv, 0)
|
||||
cipher.process(data)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Import archive data from a folder on the system. Does not restore attachments.
|
||||
*/
|
||||
@@ -300,10 +320,7 @@ object LocalArchiver {
|
||||
val metadataKey = messageBackupKey.deriveLocalBackupMetadataKey()
|
||||
val iv = encryptedBackupId.iv.toByteArray()
|
||||
val backupIdCipher = encryptedBackupId.encryptedId.toByteArray()
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
|
||||
val plaintext = cipher.doFinal(backupIdCipher)
|
||||
val plaintext = applyCipher(backupIdCipher, metadataKey, iv)
|
||||
|
||||
return BackupId(plaintext)
|
||||
}
|
||||
@@ -387,7 +404,7 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
private fun post(progress: LocalBackupCreationProgress) {
|
||||
SignalStore.backup.newLocalBackupProgress = progress
|
||||
LocalExportProgress.setEncryptedProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +459,7 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
private fun post(progress: LocalBackupCreationProgress) {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgress = progress
|
||||
LocalExportProgress.setPlaintextProgress(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ private fun FeatureBullet(text: String) {
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
|
||||
imageVector = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_check_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* Sheet displayed when the user's backup restoration failed during media import. Generally due
|
||||
* to the files no longer being available.
|
||||
*/
|
||||
class CouldNotCompleteBackupRestoreSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
CouldNotCompleteBackupRestoreSheetContent(
|
||||
onOkClick = { dismiss() },
|
||||
onLearnMoreClick = {
|
||||
dismiss()
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.backup_support_url))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CouldNotCompleteBackupRestoreSheetContent(
|
||||
onOkClick: () -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
val ok = stringResource(android.R.string.ok)
|
||||
val primaryActionButtonState: BackupAlertActionButtonState = remember(ok, onOkClick) {
|
||||
BackupAlertActionButtonState(
|
||||
label = ok,
|
||||
callback = onOkClick
|
||||
)
|
||||
}
|
||||
|
||||
val learnMore = stringResource(R.string.preferences__app_icon_learn_more)
|
||||
val secondaryActionButtonState: BackupAlertActionButtonState = remember(learnMore, onLearnMoreClick) {
|
||||
BackupAlertActionButtonState(
|
||||
label = learnMore,
|
||||
callback = onLearnMoreClick
|
||||
)
|
||||
}
|
||||
|
||||
BackupAlertBottomSheetContainer(
|
||||
icon = {
|
||||
BackupAlertIcon(iconColors = BackupsIconColors.Error)
|
||||
},
|
||||
title = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__title),
|
||||
primaryActionButtonState = primaryActionButtonState,
|
||||
secondaryActionButtonState = secondaryActionButtonState
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_error)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_retry)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CouldNotCompleteBackupRestoreSheetContentPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
CouldNotCompleteBackupRestoreSheetContent()
|
||||
}
|
||||
}
|
||||
@@ -164,10 +164,10 @@ private fun ArchiveRestoreProgressState.iconResource(): Int {
|
||||
RestoreStatus.WAITING_FOR_INTERNET,
|
||||
RestoreStatus.WAITING_FOR_WIFI,
|
||||
RestoreStatus.LOW_BATTERY -> R.drawable.symbol_backup_light
|
||||
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> R.drawable.symbol_backup_error_24
|
||||
RestoreStatus.FINISHED -> CoreUiR.drawable.symbol_check_circle_24
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,8 @@ private fun ArchiveRestoreProgressState.iconColor(): Color {
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
|
||||
|
||||
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +234,8 @@ private fun ArchiveRestoreProgressState.title(): String {
|
||||
}
|
||||
|
||||
RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +279,8 @@ private fun ArchiveRestoreProgressState.status(): String? {
|
||||
RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatus__status_device_has_low_battery)
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> null
|
||||
RestoreStatus.FINISHED -> this.totalToRestoreThisRun.toUnitString()
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.exportProgress
|
||||
import org.thoughtcrime.securesms.backup.transferProgress
|
||||
@@ -32,7 +35,8 @@ import org.signal.core.ui.R as CoreUiR
|
||||
fun BackupCreationProgressRow(
|
||||
progress: LocalBackupCreationProgress,
|
||||
isRemote: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onCancel: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -42,7 +46,7 @@ fun BackupCreationProgressRow(
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
BackupCreationProgressIndicator(progress = progress)
|
||||
BackupCreationProgressIndicator(progress = progress, onCancel = onCancel)
|
||||
|
||||
Text(
|
||||
text = getProgressMessage(progress, isRemote),
|
||||
@@ -55,7 +59,8 @@ fun BackupCreationProgressRow(
|
||||
|
||||
@Composable
|
||||
private fun BackupCreationProgressIndicator(
|
||||
progress: LocalBackupCreationProgress
|
||||
progress: LocalBackupCreationProgress,
|
||||
onCancel: (() -> Unit)? = null
|
||||
) {
|
||||
val exporting = progress.exporting
|
||||
val transferring = progress.transferring
|
||||
@@ -93,6 +98,15 @@ private fun BackupCreationProgressIndicator(
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (onCancel != null) {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.X.imageVector,
|
||||
contentDescription = "Cancel"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +238,8 @@ private fun TransferringRemotePreview() {
|
||||
mediaPhase = true
|
||||
)
|
||||
),
|
||||
isRemote = true
|
||||
isRemote = true,
|
||||
onCancel = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,8 @@ private fun progressColor(backupStatusData: ArchiveRestoreProgressState): Color
|
||||
RestoreStatus.LOW_BATTERY,
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
|
||||
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
|
||||
RestoreStatus.NONE -> BackupsIconColors.Normal.foreground
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> BackupsIconColors.Normal.foreground
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.next
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.next
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -67,7 +68,7 @@ class CallEventCache(
|
||||
|
||||
val output = mutableListOf<CallLogRow.Call>()
|
||||
val groupCallStateMap = mutableMapOf<Long, CallLogRow.GroupCallState>()
|
||||
val canUserBeginCallMap = mutableMapOf<Long, Boolean>()
|
||||
val canUserBeginCallMap = mutableMapOf<Long, CallLogRow.CanStartCall>()
|
||||
val callLinksSeen = hashSetOf<Long>()
|
||||
|
||||
while (recordIterator.hasNext()) {
|
||||
@@ -85,7 +86,7 @@ class CallEventCache(
|
||||
private fun ListIterator<CacheRecord>.readNextCallLog(
|
||||
filterState: FilterState,
|
||||
groupCallStateMap: MutableMap<Long, CallLogRow.GroupCallState>,
|
||||
canUserBeginCallMap: MutableMap<Long, Boolean>,
|
||||
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>,
|
||||
callLinksSeen: MutableSet<Long>
|
||||
): CallLogRow.Call? {
|
||||
val parent = next()
|
||||
@@ -143,14 +144,16 @@ class CallEventCache(
|
||||
return (child.timestamp - parent.timestamp) <= 4.hours.inWholeMilliseconds
|
||||
}
|
||||
|
||||
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): Boolean {
|
||||
return if (peer.isGroup && decryptedGroup != null) {
|
||||
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): CallLogRow.CanStartCall {
|
||||
if (peer.isGroup && decryptedGroup != null) {
|
||||
val proto = DecryptedGroup.ADAPTER.decode(decryptedGroup)
|
||||
return proto.isAnnouncementGroup != EnabledState.ENABLED ||
|
||||
proto.members.firstOrNull() { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role == Member.Role.ADMINISTRATOR
|
||||
} else {
|
||||
true
|
||||
when {
|
||||
proto.terminated -> return CallLogRow.CanStartCall.GROUP_TERMINATED
|
||||
DecryptedGroupUtil.findMemberByAci(proto.members, SignalStore.account.requireAci()).isEmpty -> return CallLogRow.CanStartCall.NOT_A_MEMBER
|
||||
proto.isAnnouncementGroup == EnabledState.ENABLED && proto.members.firstOrNull { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role != Member.Role.ADMINISTRATOR -> return CallLogRow.CanStartCall.ADMIN_ONLY
|
||||
}
|
||||
}
|
||||
return CallLogRow.CanStartCall.ALLOWED
|
||||
}
|
||||
|
||||
private fun getGroupCallState(body: String?): CallLogRow.GroupCallState {
|
||||
@@ -167,7 +170,7 @@ class CallEventCache(
|
||||
children: Set<Long>,
|
||||
filterState: FilterState,
|
||||
groupCallStateCache: MutableMap<Long, CallLogRow.GroupCallState>,
|
||||
canUserBeginCallMap: MutableMap<Long, Boolean>
|
||||
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>
|
||||
): CallLogRow.Call {
|
||||
val peer = Recipient.resolved(RecipientId.from(parent.peer))
|
||||
return CallLogRow.Call(
|
||||
@@ -195,10 +198,10 @@ class CallEventCache(
|
||||
searchQuery = filterState.query,
|
||||
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
|
||||
canUserBeginCall = if (peer.isGroup) {
|
||||
if (peer.isActiveGroup) {
|
||||
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
|
||||
} else false
|
||||
} else true
|
||||
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
|
||||
} else {
|
||||
CallLogRow.CanStartCall.ALLOWED
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ class CallLogAdapter(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
|
||||
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
|
||||
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
|
||||
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
|
||||
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallLinkModel) {
|
||||
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
|
||||
@@ -280,7 +280,7 @@ class CallLogAdapter(
|
||||
}
|
||||
)
|
||||
binding.groupCallButton.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient, true)
|
||||
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
|
||||
}
|
||||
binding.callType.visible = false
|
||||
binding.groupCallButton.visible = true
|
||||
@@ -288,7 +288,7 @@ class CallLogAdapter(
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient, true)
|
||||
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
|
||||
}
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
@@ -301,7 +301,7 @@ class CallLogAdapter(
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
|
||||
private val onStartAudioCallClicked: (Recipient) -> Unit,
|
||||
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
|
||||
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
|
||||
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallModel) {
|
||||
itemView.setOnClickListener {
|
||||
@@ -401,7 +401,7 @@ class CallLogAdapter(
|
||||
CallTable.Type.VIDEO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, true) }
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, CallLogRow.CanStartCall.ALLOWED) }
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
@@ -574,6 +574,6 @@ class CallLogAdapter(
|
||||
/**
|
||||
* Invoked when user presses the video icon
|
||||
*/
|
||||
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean)
|
||||
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,18 +364,21 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
|
||||
if (canUserBeginCall) {
|
||||
CommunicationActions.startVideoCall(this, recipient) {
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call),
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall) {
|
||||
when (canUserBeginCall) {
|
||||
CallLogRow.CanStartCall.ALLOWED -> {
|
||||
CommunicationActions.startVideoCall(this, recipient) {
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call),
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
|
||||
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
|
||||
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
|
||||
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ sealed class CallLogRow {
|
||||
val children: Set<Long>,
|
||||
val searchQuery: String?,
|
||||
val callLinkPeekInfo: CallLinkPeekInfo?,
|
||||
val canUserBeginCall: Boolean,
|
||||
val canUserBeginCall: CanStartCall,
|
||||
override val id: Id = Id.Call(children)
|
||||
) : CallLogRow()
|
||||
|
||||
@@ -111,4 +111,11 @@ sealed class CallLogRow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class CanStartCall {
|
||||
ALLOWED,
|
||||
ADMIN_ONLY,
|
||||
NOT_A_MEMBER,
|
||||
GROUP_TERMINATED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ 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
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -413,7 +414,7 @@ private fun IssueChip(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isSelected) {
|
||||
ImageVector.vectorResource(R.drawable.symbol_check_24)
|
||||
ImageVector.vectorResource(CoreUiR.drawable.symbol_check_24)
|
||||
} else {
|
||||
ImageVector.vectorResource(issue.category.icon)
|
||||
},
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package org.thoughtcrime.securesms.color;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class MaterialColors {
|
||||
|
||||
public static final MaterialColorList CONVERSATION_PALETTE = new MaterialColorList(new ArrayList<>(Arrays.asList(
|
||||
MaterialColor.PLUM,
|
||||
MaterialColor.CRIMSON,
|
||||
MaterialColor.VERMILLION,
|
||||
MaterialColor.VIOLET,
|
||||
MaterialColor.INDIGO,
|
||||
MaterialColor.TAUPE,
|
||||
MaterialColor.ULTRAMARINE,
|
||||
MaterialColor.BLUE,
|
||||
MaterialColor.TEAL,
|
||||
MaterialColor.FOREST,
|
||||
MaterialColor.WINTERGREEN,
|
||||
MaterialColor.BURLAP,
|
||||
MaterialColor.STEEL
|
||||
)));
|
||||
|
||||
public static class MaterialColorList {
|
||||
|
||||
private final List<MaterialColor> colors;
|
||||
|
||||
private MaterialColorList(List<MaterialColor> colors) {
|
||||
this.colors = colors;
|
||||
}
|
||||
|
||||
public MaterialColor get(int index) {
|
||||
return colors.get(index);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return colors.size();
|
||||
}
|
||||
|
||||
public @Nullable MaterialColor getByColor(Context context, int colorValue) {
|
||||
for (MaterialColor color : colors) {
|
||||
if (color.represents(context, colorValue)) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public @ColorInt int[] asConversationColorArray(@NonNull Context context) {
|
||||
int[] results = new int[colors.size()];
|
||||
int index = 0;
|
||||
|
||||
for (MaterialColor color : colors) {
|
||||
results[index++] = color.toConversationColor(context);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Selection;
|
||||
@@ -26,9 +25,6 @@ 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;
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
||||
|
||||
import org.signal.core.util.StringUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -69,7 +65,6 @@ public class ComposeText extends EmojiEditText {
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
private MessageSendType lastMessageSendType;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
|
||||
@Nullable private StylingChangedListener stylingChangedListener;
|
||||
@@ -247,20 +242,7 @@ public class ComposeText extends EmojiEditText {
|
||||
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
|
||||
}
|
||||
|
||||
if (mediaListener == null) {
|
||||
return inputConnection;
|
||||
}
|
||||
|
||||
if (inputConnection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
|
||||
this.mediaListener = mediaListener;
|
||||
return inputConnection;
|
||||
}
|
||||
|
||||
public boolean hasMentions() {
|
||||
@@ -577,38 +559,6 @@ public class ComposeText extends EmojiEditText {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
|
||||
|
||||
private static final String TAG = Log.tag(CommitContentListener.class);
|
||||
|
||||
private final InputPanel.MediaListener mediaListener;
|
||||
|
||||
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
|
||||
this.mediaListener = mediaListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
|
||||
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
|
||||
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
|
||||
inputContentInfo.getDescription().getMimeType(0));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class QueryStart {
|
||||
public int index;
|
||||
public boolean isMentionQuery;
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.hardware.Camera;
|
||||
import android.net.Uri;
|
||||
import android.text.SpannableString;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.AttributeSet;
|
||||
@@ -208,10 +207,6 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
}
|
||||
|
||||
public void setMediaListener(@NonNull MediaListener listener) {
|
||||
composeText.setMediaListener(listener);
|
||||
}
|
||||
|
||||
public void setQuote(@NonNull RequestManager requestManager,
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@@ -954,8 +949,4 @@ public class InputPanel extends ConstraintLayout
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public interface MediaListener {
|
||||
void onMediaSelected(@NonNull Uri uri, String contentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,8 +289,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
|
||||
|
||||
QuoteViewColorTheme colorTheme = getColorTheme();
|
||||
int foregroundColor = colorTheme.getForegroundColor(getContext());
|
||||
authorView.setSender(name, foregroundColor);
|
||||
authorView.setLabel(memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
|
||||
authorView.bind(name, foregroundColor, memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
|
||||
}
|
||||
|
||||
private boolean isStoryReply() {
|
||||
|
||||
@@ -16,15 +16,14 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
@@ -116,7 +115,7 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
|
||||
this.locale = locale;
|
||||
this.contact = contact;
|
||||
|
||||
Stream.of(activeRecipients.values()).forEach(recipient -> recipient.removeForeverObserver(this));
|
||||
activeRecipients.values().stream().forEach(recipient -> recipient.removeForeverObserver(this));
|
||||
this.activeRecipients.clear();
|
||||
|
||||
presentContact(contact);
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.annotation.UiThread;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import com.google.android.material.color.MaterialColors;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
@@ -347,6 +348,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
transferControlViewStub.setVisibility(View.GONE);
|
||||
playOverlay.setVisibility(View.GONE);
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
|
||||
requestManager.clear(blurHash);
|
||||
blurHash.setImageDrawable(null);
|
||||
@@ -407,6 +409,8 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (this.slide != null && this.slide.getFastPreflightId() != null &&
|
||||
this.slide.isInProgress() == slide.isInProgress() &&
|
||||
image.getDrawable() != null &&
|
||||
(!slide.hasVideo() || Util.equals(this.slide.getUri(), slide.getUri())) &&
|
||||
Util.equals(this.slide.getFastPreflightId(), slide.getFastPreflightId()))
|
||||
{
|
||||
@@ -486,6 +490,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
image.setImageDrawable(null);
|
||||
}
|
||||
|
||||
if (slide.getTransferState() == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && slide.getDisplayUri() == null) {
|
||||
setBackgroundColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, Color.GRAY));
|
||||
} else {
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
if (!resultHandled) {
|
||||
result.set(false);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.signal.core.util.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -140,7 +139,7 @@ public class TypingStatusRepository {
|
||||
|
||||
notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage));
|
||||
|
||||
Set<Long> activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
|
||||
Set<Long> activeThreads = typistMap.keySet().stream().filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
|
||||
threadsNotifier.postValue(activeThreads);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Applies Signal or System emoji to the given content based off user settings.
|
||||
* Applies Signal or System emoji to the given content based on user settings.
|
||||
*
|
||||
* Text is transformed and passed to content as an annotated string and inline content map.
|
||||
*/
|
||||
@Composable
|
||||
fun Emojifier(
|
||||
text: String,
|
||||
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
|
||||
useSystemEmoji: Boolean = LocalInspectionMode.current || SignalStore.settings.isPreferSystemEmoji,
|
||||
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
|
||||
Text(
|
||||
text = annotatedText,
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
import com.fasterxml.jackson.databind.type.CollectionType;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
|
||||
@@ -72,7 +72,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
|
||||
@Override public List<Emoji> getDisplayEmoji() {
|
||||
return Stream.of(getEmoji()).map(Emoji::new).toList();
|
||||
return getEmoji().stream().map(Emoji::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override public @Nullable Uri getSpriteUri() {
|
||||
|
||||
@@ -8,13 +8,14 @@ import android.text.Spanned;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* This wraps an Android standard {@link Annotation} so it can leverage the built in
|
||||
@@ -51,13 +52,12 @@ public final class MentionAnnotation {
|
||||
public static @NonNull List<Mention> getMentionsFromAnnotations(@Nullable CharSequence text) {
|
||||
if (text instanceof Spanned) {
|
||||
Spanned spanned = (Spanned) text;
|
||||
return Stream.of(getMentionAnnotations(spanned))
|
||||
.map(annotation -> {
|
||||
return getMentionAnnotations(spanned).stream()
|
||||
.map(annotation -> {
|
||||
int spanStart = spanned.getSpanStart(annotation);
|
||||
int spanLength = spanned.getSpanEnd(annotation) - spanStart;
|
||||
return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength);
|
||||
})
|
||||
.toList();
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -68,7 +68,6 @@ public final class MentionAnnotation {
|
||||
|
||||
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
|
||||
return Stream.of(spanned.getSpans(start, end, Annotation.class))
|
||||
.filter(MentionAnnotation::isMentionAnnotation)
|
||||
.toList();
|
||||
.filter(MentionAnnotation::isMentionAnnotation).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
appSettingsRoute.threadIds
|
||||
)
|
||||
|
||||
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
|
||||
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
|
||||
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
|
||||
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
|
||||
@@ -214,6 +215,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
@JvmOverloads
|
||||
fun remoteBackups(context: Context, forQuickRestore: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote(forQuickRestore = forQuickRestore))
|
||||
|
||||
@JvmStatic
|
||||
fun chats(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatsRoute.Chats)
|
||||
|
||||
@JvmStatic
|
||||
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)
|
||||
|
||||
|
||||
@@ -414,19 +414,8 @@ private fun AppSettingsContent(
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Backup.imageVector,
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
icon = SignalIcons.Backup.imageVector,
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -74,7 +75,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalBackupProgressFlow.collect { progress ->
|
||||
LocalExportProgress.encryptedProgress.collect { progress ->
|
||||
internalSettingsState.update { it.copy(progress = progress) }
|
||||
}
|
||||
}
|
||||
@@ -108,7 +109,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
|
||||
fun onBackupStarted() {
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE)))
|
||||
}
|
||||
|
||||
fun turnOffAndDelete(context: Context) {
|
||||
|
||||
@@ -335,7 +335,7 @@ class ChangeNumberRepository(
|
||||
} else {
|
||||
PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
|
||||
}
|
||||
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
|
||||
// Last-resort kyber prekeys
|
||||
val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) {
|
||||
@@ -343,7 +343,7 @@ class ChangeNumberRepository(
|
||||
} else {
|
||||
PreKeyUtil.generateLastResortKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
|
||||
}
|
||||
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
|
||||
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id.toLong(), lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
|
||||
|
||||
// Registration Ids
|
||||
var pniRegistrationId = -1
|
||||
@@ -383,8 +383,8 @@ class ChangeNumberRepository(
|
||||
previousPni = SignalStore.account.pni!!.toByteString(),
|
||||
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
|
||||
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
|
||||
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId,
|
||||
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId,
|
||||
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId.toInt(),
|
||||
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId.toInt(),
|
||||
previousE164 = SignalStore.account.requireE164(),
|
||||
newE164 = newE164
|
||||
)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Dialogs displayed while processing a user's decrypted chat export.
|
||||
*
|
||||
* Displayed *after* the user has confirmed via phone auth.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatExportDialogs(state: ChatExportState, callbacks: ChatExportCallbacks) {
|
||||
val folderPicker = Launchers.rememberOpenDocumentTreeLauncher {
|
||||
if (it != null) {
|
||||
callbacks.onFolderSelected(it)
|
||||
} else {
|
||||
callbacks.onCancelStartExport()
|
||||
}
|
||||
}
|
||||
|
||||
when (state) {
|
||||
ChatExportState.None -> Unit
|
||||
ChatExportState.ConfirmExport -> ConfirmExportDialog(
|
||||
onConfirmExport = callbacks::onConfirmExport,
|
||||
onCancel = callbacks::onCancelStartExport
|
||||
)
|
||||
|
||||
ChatExportState.ChooseAFolder -> ChooseAFolderDialog(
|
||||
onChooseAFolder = { folderPicker.launch(null) },
|
||||
onCancel = callbacks::onCancelStartExport
|
||||
)
|
||||
|
||||
ChatExportState.Canceling -> Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.ChatExportDialogs__canceling_export))
|
||||
|
||||
ChatExportState.Success -> CompleteDialog(
|
||||
onOK = callbacks::onCompletionConfirmed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmExportDialog(
|
||||
onConfirmExport: (withMedia: Boolean) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val body = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.ChatExportDialogs__be_careful_warning))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.ChatExportDialogs__export_confirm_body))
|
||||
}
|
||||
|
||||
Dialogs.AdvancedAlertDialog(
|
||||
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_chat_history_title)),
|
||||
body = body,
|
||||
positive = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_with_media)),
|
||||
neutral = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_without_media)),
|
||||
negative = AnnotatedString(stringResource(android.R.string.cancel)),
|
||||
onPositive = { onConfirmExport(true) },
|
||||
onNeutral = { onConfirmExport(false) },
|
||||
onNegative = onCancel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseAFolderDialog(
|
||||
onChooseAFolder: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.ChatExportDialogs__choose_a_folder_title),
|
||||
body = stringResource(R.string.ChatExportDialogs__choose_a_folder_body),
|
||||
confirm = stringResource(R.string.ChatExportDialogs__choose_folder_button),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = onChooseAFolder,
|
||||
onDeny = onCancel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompleteDialog(
|
||||
onOK: () -> Unit
|
||||
) {
|
||||
val body = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.ChatExportDialogs__be_careful))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.ChatExportDialogs__complete_body))
|
||||
}
|
||||
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__complete_title)),
|
||||
body = body,
|
||||
confirm = AnnotatedString(stringResource(android.R.string.ok)),
|
||||
onConfirm = onOK
|
||||
)
|
||||
}
|
||||
|
||||
enum class ChatExportState {
|
||||
None,
|
||||
ConfirmExport,
|
||||
ChooseAFolder,
|
||||
Canceling,
|
||||
Success
|
||||
}
|
||||
|
||||
interface ChatExportCallbacks {
|
||||
fun onConfirmExport(withMedia: Boolean)
|
||||
fun onFolderSelected(uri: Uri)
|
||||
fun onCancelStartExport()
|
||||
fun onCompletionConfirmed()
|
||||
|
||||
object Empty : ChatExportCallbacks {
|
||||
override fun onConfirmExport(withMedia: Boolean) = Unit
|
||||
override fun onFolderSelected(uri: Uri) = Unit
|
||||
override fun onCancelStartExport() = Unit
|
||||
override fun onCompletionConfirmed() = Unit
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
@@ -18,9 +22,14 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
|
||||
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
@@ -79,10 +88,38 @@ class ChatsSettingsFragment : ComposeFragment() {
|
||||
override fun onEnterKeySendsChanged(enabled: Boolean) {
|
||||
viewModel.setEnterKeySends(enabled)
|
||||
}
|
||||
|
||||
override fun onExportPlaintextChatHistoryClick() {
|
||||
viewModel.requestChatExportType()
|
||||
}
|
||||
|
||||
override fun onCancelInFlightExport() {
|
||||
viewModel.cancelChatExport()
|
||||
}
|
||||
|
||||
// region ChatExportCallback
|
||||
|
||||
override fun onConfirmExport(withMedia: Boolean) {
|
||||
viewModel.setExportTypeAndGoToSelectFolder(withMedia)
|
||||
}
|
||||
|
||||
override fun onFolderSelected(uri: Uri) {
|
||||
viewModel.startChatExportToFolder(uri)
|
||||
}
|
||||
|
||||
override fun onCancelStartExport() {
|
||||
viewModel.clearChatExportFlow()
|
||||
}
|
||||
|
||||
override fun onCompletionConfirmed() {
|
||||
viewModel.clearChatExportFlow()
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
|
||||
private interface ChatsSettingsCallbacks {
|
||||
private interface ChatsSettingsCallbacks : ChatExportCallbacks {
|
||||
fun onNavigationClick() = Unit
|
||||
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
|
||||
fun onUseAddressBookChanged(enabled: Boolean) = Unit
|
||||
@@ -91,8 +128,10 @@ private interface ChatsSettingsCallbacks {
|
||||
fun onAddOrEditFoldersClick() = Unit
|
||||
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
|
||||
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
|
||||
fun onExportPlaintextChatHistoryClick() = Unit
|
||||
fun onCancelInFlightExport() = Unit
|
||||
|
||||
object Empty : ChatsSettingsCallbacks
|
||||
object Empty : ChatsSettingsCallbacks, ChatExportCallbacks by ChatExportCallbacks.Empty
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -100,10 +139,25 @@ private fun ChatsSettingsScreen(
|
||||
state: ChatsSettingsState,
|
||||
callbacks: ChatsSettingsCallbacks
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val authenticationFailedMessage = stringResource(R.string.ChatsSettingsFragment__authentication_failed)
|
||||
val plaintextBiometricsAuthentication = rememberBiometricsAuthentication(
|
||||
promptTitle = stringResource(R.string.ChatsSettingsFragment__unlock_to_export_chat_history),
|
||||
onAuthenticationFailed = {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(authenticationFailedMessage)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.preferences_chats__chats),
|
||||
onNavigationClick = callbacks::onNavigationClick,
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
snackbarHost = {
|
||||
Snackbars.Host(snackbarHostState)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -167,6 +221,36 @@ private fun ChatsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isPlaintextExportEnabled) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
if (state.plaintextExportProgress.isIdle) {
|
||||
item(key = "export_chat_history_row") {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
text = stringResource(R.string.ChatsSettingsFragment__export_chat_history),
|
||||
label = stringResource(R.string.ChatsSettingsFragment__export_chat_history_label),
|
||||
onClick = {
|
||||
plaintextBiometricsAuthentication.withBiometricsAuthentication {
|
||||
callbacks.onExportPlaintextChatHistoryClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item(key = "export_chat_history_progress") {
|
||||
BackupCreationProgressRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
progress = state.plaintextExportProgress,
|
||||
isRemote = false,
|
||||
onCancel = callbacks::onCancelInFlightExport
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
@@ -194,6 +278,13 @@ private fun ChatsSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isPlaintextExportEnabled) {
|
||||
ChatExportDialogs(
|
||||
state = state.chatExportState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@@ -210,7 +301,9 @@ private fun ChatsSettingsScreenPreview() {
|
||||
localBackupsEnabled = true,
|
||||
folderCount = 1,
|
||||
userUnregistered = false,
|
||||
clientDeprecated = false
|
||||
clientDeprecated = false,
|
||||
isPlaintextExportEnabled = true,
|
||||
plaintextExportProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
),
|
||||
callbacks = ChatsSettingsCallbacks.Empty
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
data class ChatsSettingsState(
|
||||
val generateLinkPreviews: Boolean,
|
||||
val useAddressBook: Boolean,
|
||||
@@ -9,7 +12,11 @@ data class ChatsSettingsState(
|
||||
val localBackupsEnabled: Boolean,
|
||||
val folderCount: Int,
|
||||
val userUnregistered: Boolean,
|
||||
val clientDeprecated: Boolean
|
||||
val clientDeprecated: Boolean,
|
||||
val isPlaintextExportEnabled: Boolean,
|
||||
val plaintextExportProgress: LocalBackupCreationProgress = LocalExportProgress.plaintextProgress.value,
|
||||
val chatExportState: ChatExportState = ChatExportState.None,
|
||||
val includeMediaInExport: Boolean = false
|
||||
) {
|
||||
fun isRegisteredAndUpToDate(): Boolean {
|
||||
return !userUnregistered && !clientDeprecated
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -7,11 +8,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
|
||||
@@ -31,12 +35,53 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
|
||||
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
|
||||
folderCount = 0,
|
||||
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated,
|
||||
isPlaintextExportEnabled = RemoteConfig.localPlaintextExport,
|
||||
chatExportState = ChatExportState.None
|
||||
)
|
||||
)
|
||||
|
||||
val state: StateFlow<ChatsSettingsState> = store
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
LocalExportProgress.plaintextProgress.collect { progress ->
|
||||
store.update {
|
||||
it.copy(
|
||||
plaintextExportProgress = progress,
|
||||
chatExportState = when {
|
||||
progress.succeeded != null && it.plaintextExportProgress.succeeded == null -> ChatExportState.Success
|
||||
progress.canceled != null -> ChatExportState.None
|
||||
else -> it.chatExportState
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestChatExportType() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.ConfirmExport) }
|
||||
}
|
||||
|
||||
fun setExportTypeAndGoToSelectFolder(includeMediaInExport: Boolean) {
|
||||
store.update { it.copy(chatExportState = ChatExportState.ChooseAFolder, includeMediaInExport = includeMediaInExport) }
|
||||
}
|
||||
|
||||
fun startChatExportToFolder(uri: Uri) {
|
||||
store.update { it.copy(chatExportState = ChatExportState.None) }
|
||||
LocalBackupJob.enqueuePlaintextArchive(uri.toString(), store.value.includeMediaInExport)
|
||||
}
|
||||
|
||||
fun clearChatExportFlow() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.None, includeMediaInExport = false) }
|
||||
}
|
||||
|
||||
fun cancelChatExport() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.Canceling) }
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.PLAINTEXT_ARCHIVE_QUEUE)
|
||||
}
|
||||
|
||||
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
|
||||
store.update { it.copy(generateLinkPreviews = enabled) }
|
||||
SignalStore.settings.isLinkPreviewsEnabled = enabled
|
||||
|
||||
@@ -52,10 +52,10 @@ import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
|
||||
import org.signal.core.ui.compose.list.ReorderListEvent
|
||||
import org.signal.core.ui.compose.list.ReorderableItem
|
||||
import org.signal.core.ui.compose.list.rememberReorderableListState
|
||||
import org.signal.core.ui.compose.list.reorderableList
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -101,11 +101,11 @@ class ChatFoldersFragment : ComposeFragment() {
|
||||
onDeleteDismissed = {
|
||||
viewModel.showDeleteDialog(false)
|
||||
},
|
||||
onDragAndDropEvent = { event ->
|
||||
onReorderListEvent = { event ->
|
||||
when (event) {
|
||||
is DragAndDropEvent.OnItemMove -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
|
||||
is DragAndDropEvent.OnItemDrop -> viewModel.saveItemPositions()
|
||||
is DragAndDropEvent.OnDragCancel -> {}
|
||||
is ReorderListEvent.ItemMoved -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
|
||||
is ReorderListEvent.ItemDropped -> viewModel.saveItemPositions()
|
||||
is ReorderListEvent.DragCanceled -> {}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -123,10 +123,10 @@ fun FoldersScreen(
|
||||
onDeleteClicked: (ChatFolderRecord) -> Unit = {},
|
||||
onDeleteConfirmed: () -> Unit = {},
|
||||
onDeleteDismissed: () -> Unit = {},
|
||||
onDragAndDropEvent: (DragAndDropEvent) -> Unit = {}
|
||||
onReorderListEvent: (ReorderListEvent) -> Unit = {}
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = onDragAndDropEvent)
|
||||
val reorderableListState = rememberReorderableListState(listState, includeHeader = true, includeFooter = true, onEvent = onReorderListEvent)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!SignalStore.uiHints.hasSeenChatFoldersEducationSheet) {
|
||||
@@ -147,14 +147,14 @@ fun FoldersScreen(
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.dragContainer(
|
||||
dragDropState = dragDropState,
|
||||
modifier = Modifier.reorderableList(
|
||||
reorderableListState = reorderableListState,
|
||||
dragHandleWidth = 56.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
item {
|
||||
DraggableItem(dragDropState, 0) {
|
||||
ReorderableItem(reorderableListState, 0) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -175,7 +175,7 @@ fun FoldersScreen(
|
||||
}
|
||||
|
||||
itemsIndexed(state.folders) { index, folder ->
|
||||
DraggableItem(dragDropState, 1 + index) { isDragging ->
|
||||
ReorderableItem(reorderableListState, 1 + index) { isDragging ->
|
||||
val elevation = if (isDragging) 1.dp else 0.dp
|
||||
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
|
||||
FolderRow(
|
||||
@@ -193,7 +193,7 @@ fun FoldersScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
DraggableItem(dragDropState, 1 + state.folders.size) {
|
||||
ReorderableItem(reorderableListState, 1 + state.folders.size) {
|
||||
if (state.suggestedFolders.isNotEmpty()) {
|
||||
Dividers.Default()
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ private fun DataAndStorageSettingsScreen(
|
||||
labels = stringArrayResource(R.array.pref_media_download_entries),
|
||||
values = stringArrayResource(R.array.pref_media_download_values),
|
||||
selection = state.mobileAutoDownloadValues.toTypedArray(),
|
||||
noSelectionLabel = stringResource(R.string.preferences__none),
|
||||
onSelectionChanged = callbacks::onMobileDataAutoDownloadSelectionChanged
|
||||
)
|
||||
}
|
||||
@@ -152,6 +153,7 @@ private fun DataAndStorageSettingsScreen(
|
||||
labels = stringArrayResource(R.array.pref_media_download_entries),
|
||||
values = stringArrayResource(R.array.pref_media_download_values),
|
||||
selection = state.wifiAutoDownloadValues.toTypedArray(),
|
||||
noSelectionLabel = stringResource(R.string.preferences__none),
|
||||
onSelectionChanged = callbacks::onWifiDataAutoDownloadSelectionChanged
|
||||
)
|
||||
}
|
||||
@@ -162,6 +164,7 @@ private fun DataAndStorageSettingsScreen(
|
||||
labels = stringArrayResource(R.array.pref_media_download_entries),
|
||||
values = stringArrayResource(R.array.pref_media_download_values),
|
||||
selection = state.roamingAutoDownloadValues.toTypedArray(),
|
||||
noSelectionLabel = stringResource(R.string.preferences__none),
|
||||
onSelectionChanged = callbacks::onRoamingDataAutoDownloadSelectionChanged
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
title = DSLSettingsText.from("Collapse chat updates"),
|
||||
summary = DSLSettingsText.from("Collapses certain consecutive chat updates - cannot be undone."),
|
||||
onClick = {
|
||||
SignalStore.misc.completedCollapsedEventsMigration = false
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,6 +73,7 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.getLength
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
|
||||
@@ -270,6 +271,10 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
},
|
||||
onTriggerLocalRestoreDirectoryError = {
|
||||
SignalStore.backup.localRestoreDirectoryError = true
|
||||
ArchiveRestoreProgress.forceUpdate()
|
||||
},
|
||||
onDisplayInitialBackupFailureSheet = {
|
||||
BackupRepository.displayInitialBackupFailureNotification()
|
||||
BackupAlertBottomSheet
|
||||
@@ -366,6 +371,7 @@ fun Screen(
|
||||
onImportEncryptedBackupFromDiskConfirmed: (aci: String, backupKey: String) -> Unit = { _, _ -> },
|
||||
onClearLocalMediaBackupState: () -> Unit = {},
|
||||
onDeleteRemoteBackup: () -> Unit = {},
|
||||
onTriggerLocalRestoreDirectoryError: () -> Unit = {},
|
||||
onDisplayInitialBackupFailureSheet: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -584,6 +590,12 @@ fun Screen(
|
||||
onClick = onClearLocalMediaBackupState
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Trigger local restore directory error",
|
||||
label = "Simulates the restore directory becoming inaccessible during a local backup restore.",
|
||||
onClick = onTriggerLocalRestoreDirectoryError
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Rows.TextRow(
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
|
||||
@@ -92,7 +93,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgressFlow.collect { progress ->
|
||||
LocalExportProgress.plaintextProgress.collect { progress ->
|
||||
_state.value = _state.value.copy(plaintextProgress = progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
|
||||
private fun setDiscoverableByPhoneNumber(discoverable: Boolean) {
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (discoverable) PhoneNumberDiscoverabilityMode.DISCOVERABLE else PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
SignalDatabase.recipients.clearSelfKeyTransparencyData()
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
AppDependencies.jobManager.startChain(RefreshAttributesJob()).then(RefreshOwnProfileJob()).enqueue()
|
||||
refresh()
|
||||
|
||||
@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
|
||||
timestamp = insertedAt.inWholeMilliseconds,
|
||||
error = null,
|
||||
pendingVerification = true,
|
||||
checkedVerification = data.waitForAuth!!.checkedVerification
|
||||
checkedVerification = data.waitForAuth?.checkedVerification ?: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.qr.QrScanScreens
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -98,7 +98,7 @@ fun UsernameQrScanScreen(
|
||||
view
|
||||
},
|
||||
update = { view ->
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted())
|
||||
},
|
||||
hasPermission = hasCameraPermission,
|
||||
onRequestPermissions = onOpenCameraClicked,
|
||||
|
||||
@@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRouter
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
|
||||
import org.thoughtcrime.securesms.nicknames.NicknameActivity
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
@@ -486,7 +486,7 @@ class ConversationSettingsFragment :
|
||||
.setMessage(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story)
|
||||
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
} else if (CameraXUtil.isSupported()) {
|
||||
} else if (CameraXRemoteConfig.isSupported()) {
|
||||
addToGroupStoryDelegate.addToStory(state.recipient.id)
|
||||
} else {
|
||||
Permissions.with(this@ConversationSettingsFragment)
|
||||
@@ -718,7 +718,9 @@ class ConversationSettingsFragment :
|
||||
mediaRecords = state.sharedMedia,
|
||||
mediaIds = state.sharedMediaIds,
|
||||
onMediaRecordClick = { view, mediaRecord, isLtr ->
|
||||
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE) {
|
||||
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
|
||||
mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
|
||||
) {
|
||||
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
|
||||
return@Model
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ class ConversationSettingsRepository(
|
||||
|
||||
@WorkerThread
|
||||
fun isMessageRequestAccepted(recipient: Recipient): Boolean {
|
||||
return RecipientUtil.isMessageRequestAccepted(context, recipient)
|
||||
return RecipientUtil.isMessageRequestAccepted(recipient)
|
||||
}
|
||||
|
||||
fun getMembershipCountDescription(liveGroup: LiveGroup): LiveData<String> {
|
||||
|
||||
@@ -66,8 +66,8 @@ object CallPreference {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE -> getMissedCallString(true, call.event)
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(false, call.event) else R.string.MessageRecord_incoming_voice_call
|
||||
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(true, call.event) else R.string.MessageRecord_incoming_video_call
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.MessageRecord_outgoing_voice_call
|
||||
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.MessageRecord_outgoing_video_call
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_voice_call else R.string.MessageRecord_outgoing_voice_call
|
||||
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_video_call else R.string.MessageRecord_outgoing_video_call
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.isDisplayedAsMissedCallInUi -> if (call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE) R.string.CallPreference__missed_group_call_notification_profile else R.string.CallPreference__missed_group_call
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Process;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -23,14 +22,8 @@ import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.PlaybackParameters;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaController;
|
||||
import androidx.media3.session.MediaSession;
|
||||
import androidx.media3.session.MediaSessionService;
|
||||
import androidx.media3.session.SessionToken;
|
||||
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -45,8 +38,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
|
||||
import org.thoughtcrime.securesms.mms.PartUriParser;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -66,7 +57,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
|
||||
|
||||
private MediaSession mediaSession;
|
||||
private VoiceNotePlayer player;
|
||||
private KeyClearedReceiver keyClearedReceiver;
|
||||
private VoiceNotePlayerCallback voiceNotePlayerCallback;
|
||||
|
||||
private final DatabaseObserver.Observer attachmentDeletionObserver = this::onAttachmentDeleted;
|
||||
@@ -88,8 +78,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
|
||||
mediaSession = session;
|
||||
}
|
||||
|
||||
keyClearedReceiver = new KeyClearedReceiver(this, session.getToken());
|
||||
|
||||
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
|
||||
setListener(new MediaSessionServiceListener());
|
||||
AppDependencies.getDatabaseObserver().registerAttachmentDeletedObserver(attachmentDeletionObserver);
|
||||
@@ -121,11 +109,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
|
||||
mediaSession = null;
|
||||
}
|
||||
|
||||
KeyClearedReceiver receiver = keyClearedReceiver;
|
||||
if (receiver != null) {
|
||||
receiver.unregister();
|
||||
}
|
||||
|
||||
clearListener();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -133,6 +116,10 @@ public class VoiceNotePlaybackService extends MediaSessionService {
|
||||
@Nullable
|
||||
@Override
|
||||
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
|
||||
if (Build.VERSION.SDK_INT >= 28 && controllerInfo.getUid() != Process.myUid()) {
|
||||
Log.w(TAG, "Denying session to external caller: " + controllerInfo.getPackageName());
|
||||
return null;
|
||||
}
|
||||
return mediaSession;
|
||||
}
|
||||
|
||||
@@ -375,71 +362,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
|
||||
* This registers itself as a receiver on the [Context] as soon as it can.
|
||||
*/
|
||||
private static class KeyClearedReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = Log.tag(KeyClearedReceiver.class);
|
||||
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
|
||||
|
||||
private final Context context;
|
||||
private final ListenableFuture<MediaController> controllerFuture;
|
||||
private MediaController controller;
|
||||
|
||||
private boolean registered;
|
||||
|
||||
private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) {
|
||||
this.context = context;
|
||||
Log.d(TAG, "Creating media controller…");
|
||||
controllerFuture = new MediaController.Builder(context, token).buildAsync();
|
||||
Futures.addCallback(controllerFuture, new FutureCallback<>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable MediaController result) {
|
||||
Log.d(TAG, "Successfully created media controller.");
|
||||
controller = result;
|
||||
register();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Throwable t) {
|
||||
Log.w(TAG, "KeyClearedReceiver.onFailure", t);
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context));
|
||||
}
|
||||
|
||||
void register() {
|
||||
if (controller == null) {
|
||||
Log.w(TAG, "Failed to register KeyClearedReceiver because MediaController was null.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registered) {
|
||||
ContextCompat.registerReceiver(context, this, KEY_CLEARED_FILTER, ContextCompat.RECEIVER_NOT_EXPORTED);
|
||||
registered = true;
|
||||
Log.d(TAG, "Successfully registered.");
|
||||
}
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
if (registered) {
|
||||
context.unregisterReceiver(this);
|
||||
registered = false;
|
||||
}
|
||||
MediaController.releaseFuture(controllerFuture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (controller == null) {
|
||||
Log.w(TAG, "Received broadcast but could not stop playback because MediaController was null.");
|
||||
} else {
|
||||
Log.i(TAG, "Received broadcast, stopping playback.");
|
||||
controller.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class MediaSessionServiceListener implements Listener {
|
||||
@Override
|
||||
public void onForegroundServiceStartNotAllowedException() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
|
||||
.setHandleAudioBecomingNoisy(true).build()
|
||||
) : ForwardingPlayer(internalPlayer) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VoiceNotePlayer::class.java)
|
||||
}
|
||||
|
||||
init {
|
||||
val audioManager = ContextCompat.getSystemService(context, AudioManager::class.java)
|
||||
|
||||
@@ -47,6 +52,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener {
|
||||
if (it == AudioManager.AUDIOFOCUS_LOSS || it == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
|
||||
Log.d(TAG, "Audio focus change to $it. Pausing.")
|
||||
this.pause()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.components.voice
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.OptIn
|
||||
@@ -94,6 +96,10 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
|
||||
private var latestUri = Uri.EMPTY
|
||||
|
||||
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
|
||||
if (Build.VERSION.SDK_INT >= 28 && controller.uid != Process.myUid()) {
|
||||
Log.w(TAG, "Rejecting connection from external caller: ${controller.packageName}")
|
||||
return MediaSession.ConnectionResult.reject()
|
||||
}
|
||||
return MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
|
||||
}
|
||||
|
||||
@@ -207,6 +213,8 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
|
||||
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
|
||||
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
|
||||
player.playWhenReady = true
|
||||
} else {
|
||||
Log.i(TAG, "Audio stream set to $newStreamType. Not playing when ready.")
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
|
||||
@@ -15,7 +15,9 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionCommand
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = Log.tag(VoiceNoteProximityWakeLockManager::class.java)
|
||||
@@ -31,6 +33,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
|
||||
|
||||
private val audioManager: AudioManagerCompat = AppDependencies.androidCallAudioManager
|
||||
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
|
||||
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
|
||||
|
||||
@@ -58,7 +61,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
}
|
||||
|
||||
fun unregisterCallbacksAndRelease() {
|
||||
mediaController.addListener(mediaControllerCallback)
|
||||
mediaController.removeListener(mediaControllerCallback)
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
|
||||
@@ -91,20 +94,24 @@ class VoiceNoteProximityWakeLockManager(
|
||||
inner class ProximityListener : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_IS_PLAYING_CHANGED)) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (player.isPlaying) {
|
||||
if (startTime == -1L) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
if (wakeLock?.isHeld == false) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
|
||||
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
|
||||
if (audioManager.isHeadsetConnected) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Headset connected, skipping proximity sensor registration.")
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
if (wakeLock?.isHeld == false) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
|
||||
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
|
||||
}
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
}
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
|
||||
}
|
||||
@@ -118,11 +125,14 @@ class VoiceNoteProximityWakeLockManager(
|
||||
|
||||
inner class HardwareSensorEventListener : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (startTime == -1L ||
|
||||
System.currentTimeMillis() - startTime <= 500 ||
|
||||
if (System.currentTimeMillis() - startTime <= 500) {
|
||||
Log.i(TAG, "Ignoring sensor change because it's too close to start time.")
|
||||
return
|
||||
} else if (startTime == -1L ||
|
||||
!isActivityResumed() ||
|
||||
!mediaController.isPlaying ||
|
||||
event.sensor.type != Sensor.TYPE_PROXIMITY
|
||||
event.sensor.type != Sensor.TYPE_PROXIMITY ||
|
||||
audioManager.isHeadsetConnected()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ package org.thoughtcrime.securesms.components.webrtc;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.events.CallParticipant;
|
||||
@@ -68,12 +67,12 @@ public final class CallParticipantListUpdate {
|
||||
public static @NonNull CallParticipantListUpdate computeDeltaUpdate(@NonNull List<CallParticipant> oldList,
|
||||
@NonNull List<CallParticipant> newList)
|
||||
{
|
||||
Set<CallParticipantListUpdate.Wrapper> oldParticipants = Stream.of(oldList)
|
||||
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
|
||||
Set<CallParticipantListUpdate.Wrapper> oldParticipants = oldList.stream()
|
||||
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
|
||||
.map(CallParticipantListUpdate::createWrapper)
|
||||
.collect(Collectors.toSet());
|
||||
Set<CallParticipantListUpdate.Wrapper> newParticipants = Stream.of(newList)
|
||||
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
|
||||
Set<CallParticipantListUpdate.Wrapper> newParticipants = newList.stream()
|
||||
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
|
||||
.map(CallParticipantListUpdate::createWrapper)
|
||||
.collect(Collectors.toSet());
|
||||
Set<CallParticipantListUpdate.Wrapper> added = SetUtil.difference(newParticipants, oldParticipants);
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import androidx.annotation.Discouraged
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.annimon.stream.OptionalLong
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
|
||||
@@ -19,6 +18,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState
|
||||
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
|
||||
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ data class CallParticipantsState(
|
||||
val isInPipMode: Boolean = false,
|
||||
private val showVideoForOutgoing: Boolean = false,
|
||||
val isViewingFocusedParticipant: Boolean = false,
|
||||
val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
|
||||
val remoteDevicesCount: Optional<Long> = Optional.empty(),
|
||||
private val foldableState: FoldableState = FoldableState.flat(),
|
||||
val isInOutgoingRingingMode: Boolean = false,
|
||||
val recipient: Recipient = Recipient.UNKNOWN,
|
||||
@@ -87,11 +87,11 @@ data class CallParticipantsState(
|
||||
return listParticipants
|
||||
}
|
||||
|
||||
val participantCount: OptionalLong
|
||||
val participantCount: Optional<Long>
|
||||
get() {
|
||||
val includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED
|
||||
return remoteDevicesCount.map { l: Long -> l + if (includeSelf) 1L else 0L }
|
||||
.or { if (includeSelf) OptionalLong.of(1L) else OptionalLong.empty() }
|
||||
.or { if (includeSelf) Optional.of(1L) else Optional.empty() }
|
||||
}
|
||||
|
||||
fun getPreJoinGroupDescription(context: Context): String? {
|
||||
@@ -358,7 +358,7 @@ data class CallParticipantsState(
|
||||
@PluralsRes multipleParticipants: Int,
|
||||
members: List<GroupMemberEntry.FullMember>
|
||||
): String {
|
||||
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked }
|
||||
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked || it.member.isUnregistered }
|
||||
|
||||
return when (eligibleMembers.size) {
|
||||
0 -> noParticipants?.let { context.getString(noParticipants) } ?: ""
|
||||
|
||||
@@ -91,7 +91,7 @@ object CallInfoView {
|
||||
inCallLobby = state.callState == WebRtcViewModel.State.CALL_PRE_JOIN,
|
||||
ringGroup = state.ringGroup,
|
||||
includeSelf = state.groupCallState === WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED || state.groupCallState === WebRtcViewModel.GroupCallState.IDLE,
|
||||
participantCount = if (state.participantCount.isPresent) state.participantCount.asLong.toInt() else 0,
|
||||
participantCount = if (state.participantCount.isPresent) state.participantCount.get().toInt() else 0,
|
||||
remoteParticipants = state.allRemoteParticipants.sortedBy { it.callParticipantId.recipientId },
|
||||
localParticipant = state.localParticipant,
|
||||
groupMembers = state.groupMembers.filterNot { it.member.isSelf },
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.toLiveData
|
||||
@@ -32,10 +33,12 @@ import org.signal.core.ui.compose.AllNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -159,6 +162,7 @@ private fun ParticipantHeader(recipient: Recipient) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
@@ -176,14 +180,14 @@ private fun ParticipantHeader(recipient: Recipient) {
|
||||
|
||||
Text(
|
||||
text = recipient.getDisplayName(androidx.compose.ui.platform.LocalContext.current),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
val e164 = recipient.e164
|
||||
if (e164.isPresent) {
|
||||
if (recipient.shouldShowE164) {
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
Text(
|
||||
text = e164.get(),
|
||||
text = SignalE164Util.prettyPrint(recipient.requireE164()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -25,32 +30,27 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.AllNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
|
||||
@@ -110,9 +110,6 @@ data class GridCell(
|
||||
val height: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Internal helper for grid layout parameters
|
||||
*/
|
||||
private data class GridLayoutParams(
|
||||
val rows: Int,
|
||||
val cols: Int,
|
||||
@@ -226,9 +223,6 @@ sealed class CallGridStrategy(val maxTiles: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the appropriate CallGridStrategy based on current window size
|
||||
*/
|
||||
private const val WIDTH_DP_LARGE_LOWER_BOUND = 1200
|
||||
|
||||
@Composable
|
||||
@@ -365,7 +359,6 @@ private fun calculateGridCells(
|
||||
val actualItemsInRow = min(itemsInThisRow, remainingItems)
|
||||
val isPartialRow = actualItemsInRow < config.columns
|
||||
|
||||
// Stretch items in partial rows to fill width (compact mode only)
|
||||
val cellWidthForRow = if (config.aspectRatio == null && isPartialRow) {
|
||||
(totalGridWidth - (spacing * (actualItemsInRow - 1))) / actualItemsInRow
|
||||
} else {
|
||||
@@ -422,7 +415,6 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
val gridStartX = padding + (availableWidth - totalGridWidth) / 2
|
||||
val gridStartY = padding + (availableHeight - totalGridHeight) / 2
|
||||
|
||||
// Place regular items in column-major order (fills columns top-to-bottom, left-to-right)
|
||||
var index = 0
|
||||
for (col in 0 until columnsForRegularItems) {
|
||||
for (row in 0 until config.rows) {
|
||||
@@ -444,7 +436,6 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
}
|
||||
}
|
||||
|
||||
// Spanning item takes full height
|
||||
val spanningX = gridStartX + columnsForRegularItems * (cellWidth + spacing)
|
||||
val spanningY = gridStartY
|
||||
val spanningHeight = totalGridHeight
|
||||
@@ -463,14 +454,9 @@ private fun calculateGridCellsWithSpanningColumn(
|
||||
}
|
||||
|
||||
/**
|
||||
* State for an item that is exiting the grid with animation
|
||||
* Holds an item being tracked by [CallGrid], along with whether it should animate in on entry.
|
||||
*/
|
||||
private data class ExitingItem<T>(
|
||||
val item: T,
|
||||
val key: Any,
|
||||
val lastPosition: IntOffset,
|
||||
val lastSize: IntSize
|
||||
)
|
||||
private data class ManagedItem<T>(val item: T, val animateEnter: Boolean)
|
||||
|
||||
/**
|
||||
* An animated grid layout for call participants.
|
||||
@@ -479,7 +465,6 @@ private data class ExitingItem<T>(
|
||||
* - Smooth position animations when items move
|
||||
* - Fade-in/scale-in animation for new items (0% to 100% opacity, 90% to 100% scale)
|
||||
* - Fade-out/scale-out animation for removed items (100% to 0% opacity, 100% to 90% scale)
|
||||
* - Crossfade for swapped items (same position, different participant)
|
||||
* - Device-aware grid configurations
|
||||
*
|
||||
* @param items List of items to display, each with a stable key
|
||||
@@ -499,20 +484,9 @@ fun <T> CallGrid(
|
||||
content: @Composable (item: T, modifier: Modifier) -> Unit
|
||||
) {
|
||||
val strategy = rememberCallGridStrategy()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val positionAnimatables: SnapshotStateMap<Any, Animatable<IntOffset, *>> = remember { mutableStateMapOf() }
|
||||
val sizeAnimatables: SnapshotStateMap<Any, Animatable<IntSize, *>> = remember { mutableStateMapOf() }
|
||||
val alphaAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val scaleAnimatables: SnapshotStateMap<Any, Animatable<Float, *>> = remember { mutableStateMapOf() }
|
||||
val knownKeys = remember { mutableSetOf<Any>() }
|
||||
var exitingItems: List<ExitingItem<T>> by remember { mutableStateOf(emptyList()) }
|
||||
val previousItems = remember { mutableStateMapOf<Any, T>() }
|
||||
|
||||
val displayCount = min(items.size, strategy.maxTiles)
|
||||
val displayItems = items.take(displayCount)
|
||||
val baseConfig = remember(strategy, displayCount) { strategy.getConfig(displayCount) }
|
||||
|
||||
val config = if (displayCount == 1 && singleParticipantAspectRatio != null && baseConfig.aspectRatio != null) {
|
||||
baseConfig.copy(aspectRatio = singleParticipantAspectRatio)
|
||||
} else {
|
||||
@@ -525,226 +499,95 @@ fun <T> CallGrid(
|
||||
label = "cornerRadius"
|
||||
)
|
||||
|
||||
var containerSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
val cells = remember(config, containerSize, displayCount) {
|
||||
if (containerSize == IntSize.Zero) emptyList()
|
||||
else calculateGridCells(
|
||||
config = config,
|
||||
containerWidth = containerSize.width.toFloat(),
|
||||
containerHeight = containerSize.height.toFloat(),
|
||||
itemCount = displayCount
|
||||
)
|
||||
}
|
||||
|
||||
// Holds all items currently in the grid, including those still animating out.
|
||||
val managedItems: SnapshotStateMap<Any, ManagedItem<T>> = remember { mutableStateMapOf() }
|
||||
|
||||
// lastKnownCells freezes the last grid position for items that are animating out so they
|
||||
// stay in place (rather than jumping to zero) while their exit animation plays.
|
||||
val lastKnownCells = remember { mutableMapOf<Any, GridCell>() }
|
||||
|
||||
val currentKeys = displayItems.map { itemKey(it) }.toSet()
|
||||
val newKeys = currentKeys - knownKeys
|
||||
val hasExistingItems = knownKeys.isNotEmpty()
|
||||
val hasExistingItems = managedItems.isNotEmpty()
|
||||
|
||||
newKeys.forEach { key ->
|
||||
if (exitingItems.any { it.key == key }) {
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
}
|
||||
if (hasExistingItems) {
|
||||
alphaAnimatables[key] = Animatable(0f)
|
||||
scaleAnimatables[key] = Animatable(CallGridDefaults.ENTER_SCALE_START)
|
||||
}
|
||||
knownKeys.add(key)
|
||||
}
|
||||
|
||||
displayItems.forEach { item ->
|
||||
previousItems[itemKey(item)] = item
|
||||
}
|
||||
|
||||
fun removeAnimationState(key: Any) {
|
||||
positionAnimatables.remove(key)
|
||||
sizeAnimatables.remove(key)
|
||||
alphaAnimatables.remove(key)
|
||||
scaleAnimatables.remove(key)
|
||||
previousItems.remove(key)
|
||||
}
|
||||
|
||||
val removedKeys = knownKeys - currentKeys
|
||||
removedKeys.forEach { key ->
|
||||
val exitingItem = previousItems[key]
|
||||
val position = positionAnimatables[key]?.value
|
||||
val size = sizeAnimatables[key]?.value
|
||||
|
||||
if (exitingItem != null && position != null && size != null) {
|
||||
exitingItems = exitingItems + ExitingItem(
|
||||
item = exitingItem,
|
||||
key = key,
|
||||
lastPosition = position,
|
||||
lastSize = size
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
coroutineScope {
|
||||
launch { alphaAnimatables[key]?.animateTo(0f, CallGridDefaults.alphaAnimationSpec) }
|
||||
launch { scaleAnimatables[key]?.animateTo(CallGridDefaults.EXIT_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
|
||||
}
|
||||
exitingItems = exitingItems.filterNot { it.key == key }
|
||||
if (key !in knownKeys) {
|
||||
removeAnimationState(key)
|
||||
}
|
||||
SideEffect {
|
||||
displayItems.forEach { item ->
|
||||
val key = itemKey(item)
|
||||
if (key !in managedItems) {
|
||||
managedItems[key] = ManagedItem(item, animateEnter = hasExistingItems)
|
||||
} else {
|
||||
managedItems[key] = managedItems[key]!!.copy(item = item)
|
||||
}
|
||||
} else {
|
||||
removeAnimationState(key)
|
||||
}
|
||||
knownKeys.remove(key)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val containerWidthPx = constraints.maxWidth.toFloat()
|
||||
val containerHeightPx = constraints.maxHeight.toFloat()
|
||||
Box(modifier = modifier.onSizeChanged { containerSize = it }) {
|
||||
managedItems.entries.toList().forEach { (key, managed) ->
|
||||
val index = displayItems.indexOfFirst { itemKey(it) == key }
|
||||
val targetCell = cells.getOrNull(index)
|
||||
if (targetCell != null) lastKnownCells[key] = targetCell
|
||||
val effectiveCell = targetCell ?: lastKnownCells[key] ?: return@forEach
|
||||
|
||||
val cells = remember(config, containerWidthPx, containerHeightPx, displayCount) {
|
||||
calculateGridCells(
|
||||
config = config,
|
||||
containerWidth = containerWidthPx,
|
||||
containerHeight = containerHeightPx,
|
||||
itemCount = displayCount
|
||||
)
|
||||
}
|
||||
key(key) {
|
||||
var isVisible by remember { mutableStateOf(!managed.animateEnter) }
|
||||
LaunchedEffect(Unit) { isVisible = true }
|
||||
|
||||
val density = LocalDensity.current
|
||||
AnimatedVisibility(
|
||||
visible = isVisible && key in currentKeys,
|
||||
enter = scaleIn(
|
||||
initialScale = CallGridDefaults.ENTER_SCALE_START,
|
||||
animationSpec = CallGridDefaults.scaleAnimationSpec
|
||||
) + fadeIn(animationSpec = CallGridDefaults.alphaAnimationSpec),
|
||||
exit = scaleOut(
|
||||
targetScale = CallGridDefaults.EXIT_SCALE_END,
|
||||
animationSpec = CallGridDefaults.scaleAnimationSpec
|
||||
) + fadeOut(animationSpec = CallGridDefaults.alphaAnimationSpec)
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { managedItems.remove(key) }
|
||||
}
|
||||
|
||||
val enteringKeys = newKeys.filter { key ->
|
||||
val alpha = alphaAnimatables[key]?.value ?: 1f
|
||||
alpha < 1f
|
||||
}.toSet()
|
||||
val targetPosition = IntOffset(effectiveCell.x.roundToInt(), effectiveCell.y.roundToInt())
|
||||
val targetSize = IntSize(effectiveCell.width.roundToInt(), effectiveCell.height.roundToInt())
|
||||
|
||||
// Internal to capture closure variables: alphaAnimatables, scaleAnimatables, density, animatedCornerRadius, content
|
||||
@Composable
|
||||
fun RenderItem(item: T, itemKeyValue: Any, widthPx: Int, heightPx: Int) {
|
||||
val alpha = alphaAnimatables[itemKeyValue]?.value ?: 1f
|
||||
val itemScale = scaleAnimatables[itemKeyValue]?.value ?: 1f
|
||||
val positionAnim = remember { Animatable(targetPosition, IntOffset.VectorConverter) }
|
||||
val sizeAnim = remember { Animatable(targetSize, IntSize.VectorConverter) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layoutId(itemKeyValue)
|
||||
.alpha(alpha)
|
||||
.scale(itemScale)
|
||||
) {
|
||||
content(
|
||||
item,
|
||||
Modifier
|
||||
.size(
|
||||
width = with(density) { widthPx.toDp() },
|
||||
height = with(density) { heightPx.toDp() }
|
||||
// LaunchedEffect is tied to this composable's lifecycle and cancels automatically
|
||||
// when the item leaves composition, preventing any deactivated-node interaction.
|
||||
LaunchedEffect(targetPosition) {
|
||||
positionAnim.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec)
|
||||
}
|
||||
LaunchedEffect(targetSize) {
|
||||
sizeAnim.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.absoluteOffset { positionAnim.value }) {
|
||||
content(
|
||||
managed.item,
|
||||
Modifier
|
||||
.size(
|
||||
width = with(density) { sizeAnim.value.width.toDp() },
|
||||
height = with(density) { sizeAnim.value.height.toDp() }
|
||||
)
|
||||
.clip(RoundedCornerShape(animatedCornerRadius))
|
||||
)
|
||||
.clip(RoundedCornerShape(animatedCornerRadius))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-filter items by entering status, preserving indices for cell lookup
|
||||
val (enteringIndexedItems, nonEnteringIndexedItems) = displayItems
|
||||
.withIndex()
|
||||
.partition { (_, item) -> itemKey(item) in enteringKeys }
|
||||
|
||||
@Composable
|
||||
fun RenderDisplayItems(indexedItems: List<IndexedValue<T>>) {
|
||||
indexedItems.forEach { (index, item) ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
key(itemKeyValue) {
|
||||
val animatedSize = sizeAnimatables[itemKeyValue]?.value
|
||||
val cell = cells.getOrNull(index)
|
||||
if (cell != null) {
|
||||
val widthPx = animatedSize?.width ?: cell.width.roundToInt()
|
||||
val heightPx = animatedSize?.height ?: cell.height.roundToInt()
|
||||
RenderItem(item, itemKeyValue, widthPx, heightPx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
exitingItems.forEach { exitingItem ->
|
||||
key(exitingItem.key) {
|
||||
RenderItem(exitingItem.item, exitingItem.key, exitingItem.lastSize.width, exitingItem.lastSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
RenderDisplayItems(enteringIndexedItems)
|
||||
RenderDisplayItems(nonEnteringIndexedItems)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
displayItems.forEachIndexed { index, item ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
val cell = cells.getOrNull(index) ?: return@forEachIndexed
|
||||
val targetPosition = IntOffset(cell.x.roundToInt(), cell.y.roundToInt())
|
||||
val targetSize = IntSize(cell.width.roundToInt(), cell.height.roundToInt())
|
||||
|
||||
val existingPosition = positionAnimatables[itemKeyValue]
|
||||
if (existingPosition == null) {
|
||||
positionAnimatables[itemKeyValue] = Animatable(targetPosition, IntOffset.VectorConverter)
|
||||
if (hasExistingItems && itemKeyValue in newKeys) {
|
||||
scope.launch {
|
||||
coroutineScope {
|
||||
launch { alphaAnimatables[itemKeyValue]?.animateTo(1f, CallGridDefaults.alphaAnimationSpec) }
|
||||
launch { scaleAnimatables[itemKeyValue]?.animateTo(CallGridDefaults.ENTER_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (alphaAnimatables[itemKeyValue] == null) {
|
||||
alphaAnimatables[itemKeyValue] = Animatable(1f)
|
||||
}
|
||||
if (scaleAnimatables[itemKeyValue] == null) {
|
||||
scaleAnimatables[itemKeyValue] = Animatable(1f)
|
||||
}
|
||||
}
|
||||
} else if (existingPosition.targetValue != targetPosition) {
|
||||
scope.launch {
|
||||
existingPosition.animateTo(targetPosition, CallGridDefaults.positionAnimationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
val existingSize = sizeAnimatables[itemKeyValue]
|
||||
if (existingSize == null) {
|
||||
sizeAnimatables[itemKeyValue] = Animatable(targetSize, IntSize.VectorConverter)
|
||||
} else if (existingSize.targetValue != targetSize) {
|
||||
scope.launch {
|
||||
existingSize.animateTo(targetSize, CallGridDefaults.sizeAnimationSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val placeables = measurables.map { measurable ->
|
||||
val itemKeyValue = measurable.layoutId
|
||||
val animatedSize = sizeAnimatables[itemKeyValue]?.value
|
||||
val exitingItem = exitingItems.find { it.key == itemKeyValue }
|
||||
|
||||
when {
|
||||
animatedSize != null -> {
|
||||
measurable.measure(Constraints.fixed(animatedSize.width, animatedSize.height))
|
||||
}
|
||||
exitingItem != null -> {
|
||||
measurable.measure(Constraints.fixed(exitingItem.lastSize.width, exitingItem.lastSize.height))
|
||||
}
|
||||
else -> {
|
||||
measurable.measure(Constraints())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val keyToPlaceable = measurables.zip(placeables).associate { (measurable, placeable) ->
|
||||
measurable.layoutId to placeable
|
||||
}
|
||||
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
fun placeDisplayItems(indexedItems: List<IndexedValue<T>>) {
|
||||
indexedItems.forEach { (_, item) ->
|
||||
val itemKeyValue = itemKey(item)
|
||||
val placeable = keyToPlaceable[itemKeyValue]
|
||||
val position = positionAnimatables[itemKeyValue]?.value
|
||||
if (placeable != null && position != null) {
|
||||
placeable.place(position.x, position.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitingItems.forEach { exitingItem ->
|
||||
val placeable = keyToPlaceable[exitingItem.key]
|
||||
placeable?.place(exitingItem.lastPosition.x, exitingItem.lastPosition.y)
|
||||
}
|
||||
|
||||
placeDisplayItems(enteringIndexedItems)
|
||||
placeDisplayItems(nonEnteringIndexedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,10 +618,7 @@ private fun CallGridPreview() {
|
||||
val windowSizeClass = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass
|
||||
val strategy = rememberCallGridStrategy()
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val widthDp = maxWidth
|
||||
val heightDp = maxHeight
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CallGrid(
|
||||
items = items,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -796,8 +636,7 @@ private fun CallGridPreview() {
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${widthDp.value.toInt()} x ${heightDp.value.toInt()} dp\n" +
|
||||
"WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
||||
text = "WSC: ${windowSizeClass.minWidthDp}x${windowSizeClass.minHeightDp}\n" +
|
||||
"Strategy: ${strategy::class.simpleName}",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
|
||||
@@ -91,7 +91,7 @@ fun RemoteParticipantContent(
|
||||
val isBlocked = recipient.isBlocked
|
||||
val isMissingMediaKeys = !participant.isMediaKeysReceived &&
|
||||
(System.currentTimeMillis() - participant.addedToCallTime) > 5000
|
||||
val infoMode = isBlocked || isMissingMediaKeys
|
||||
val infoMode = !participant.isSelf && (isBlocked || isMissingMediaKeys)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
BlurredBackgroundAvatar(recipient = recipient)
|
||||
|
||||
@@ -194,7 +194,8 @@ private fun PreJoinHeader(
|
||||
text = callStatus,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.compose
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.PixelCopy
|
||||
import android.view.View
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
|
||||
/**
|
||||
* Helper class for screenshotting compose views.
|
||||
*
|
||||
* You need to call bind from the compose, passing in the
|
||||
* LocalView.current view with bounds fetched from when the
|
||||
* composable is globally positioned.
|
||||
*
|
||||
* See QrCodeBadge.kt for an example
|
||||
*/
|
||||
class ScreenshotController {
|
||||
private var screenshotCallback: (() -> Bitmap?)? = null
|
||||
|
||||
fun bind(view: View, bounds: Rect?) {
|
||||
if (bounds == null) {
|
||||
screenshotCallback = null
|
||||
return
|
||||
}
|
||||
screenshotCallback = {
|
||||
val bitmap = Bitmap.createBitmap(
|
||||
bounds.width.toInt(),
|
||||
bounds.height.toInt(),
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
PixelCopy.request(
|
||||
(view.context as Activity).window,
|
||||
android.graphics.Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()),
|
||||
bitmap,
|
||||
{},
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
} else {
|
||||
val canvas = Canvas(bitmap)
|
||||
.apply {
|
||||
translate(-bounds.left, -bounds.top)
|
||||
}
|
||||
view.draw(canvas)
|
||||
}
|
||||
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
|
||||
fun screenshot(): Bitmap? {
|
||||
return screenshotCallback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun LayoutCoordinates.getScreenshotBounds(): Rect {
|
||||
return if (Build.VERSION.SDK_INT >= 26) {
|
||||
this.boundsInWindow()
|
||||
} else {
|
||||
this.boundsInRoot()
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
/*
|
||||
* Copyright (C) 2006 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import android.database.AbstractCursor;
|
||||
import android.database.CursorWindow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* A convenience class that presents a two-dimensional ArrayList
|
||||
* as a Cursor.
|
||||
*/
|
||||
public class ArrayListCursor extends AbstractCursor {
|
||||
private String[] mColumnNames;
|
||||
private ArrayList<Object>[] mRows;
|
||||
|
||||
@SuppressWarnings({"unchecked"})
|
||||
public ArrayListCursor(String[] columnNames, ArrayList<ArrayList> rows) {
|
||||
int colCount = columnNames.length;
|
||||
boolean foundID = false;
|
||||
// Add an _id column if not in columnNames
|
||||
for (int i = 0; i < colCount; ++i) {
|
||||
if (columnNames[i].compareToIgnoreCase("_id") == 0) {
|
||||
mColumnNames = columnNames;
|
||||
foundID = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundID) {
|
||||
mColumnNames = new String[colCount + 1];
|
||||
System.arraycopy(columnNames, 0, mColumnNames, 0, columnNames.length);
|
||||
mColumnNames[colCount] = "_id";
|
||||
}
|
||||
|
||||
int rowCount = rows.size();
|
||||
mRows = new ArrayList[rowCount];
|
||||
|
||||
for (int i = 0; i < rowCount; ++i) {
|
||||
mRows[i] = rows.get(i);
|
||||
if (!foundID) {
|
||||
mRows[i].add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fillWindow(int position, CursorWindow window) {
|
||||
if (position < 0 || position > getCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.acquireReference();
|
||||
try {
|
||||
int oldpos = mPos;
|
||||
mPos = position - 1;
|
||||
window.clear();
|
||||
window.setStartPosition(position);
|
||||
int columnNum = getColumnCount();
|
||||
window.setNumColumns(columnNum);
|
||||
while (moveToNext() && window.allocRow()) {
|
||||
for (int i = 0; i < columnNum; i++) {
|
||||
final Object data = mRows[mPos].get(i);
|
||||
if (data != null) {
|
||||
if (data instanceof byte[]) {
|
||||
byte[] field = (byte[]) data;
|
||||
if (!window.putBlob(field, mPos, i)) {
|
||||
window.freeLastRow();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
String field = data.toString();
|
||||
if (!window.putString(field, mPos, i)) {
|
||||
window.freeLastRow();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!window.putNull(mPos, i)) {
|
||||
window.freeLastRow();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mPos = oldpos;
|
||||
} catch (IllegalStateException e){
|
||||
// simply ignore it
|
||||
} finally {
|
||||
window.releaseReference();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return mRows.length;
|
||||
}
|
||||
|
||||
public boolean deleteRow() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getColumnNames() {
|
||||
return mColumnNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBlob(int columnIndex) {
|
||||
return (byte[]) mRows[mPos].get(columnIndex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(int columnIndex) {
|
||||
Object cell = mRows[mPos].get(columnIndex);
|
||||
return (cell == null) ? null : cell.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public short getShort(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.shortValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.intValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.longValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.floatValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getDouble(int columnIndex) {
|
||||
Number num = (Number) mRows[mPos].get(columnIndex);
|
||||
return num.doubleValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isNull(int columnIndex) {
|
||||
return mRows[mPos].get(columnIndex) == null;
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.contacts;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.provider.ContactsContract.CommonDataKinds.Phone;
|
||||
import android.provider.ContactsContract.Contacts;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* This class was originally a layer of indirection between
|
||||
* ContactAccessorNewApi and ContactAccessorOldApi, which corresponded
|
||||
* to the API changes between 1.x and 2.x.
|
||||
*
|
||||
* Now that we no longer support 1.x, this class mostly serves as a place
|
||||
* to encapsulate Contact-related logic. It's still a singleton, mostly
|
||||
* just because that's how it's currently called from everywhere.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
|
||||
public class ContactAccessor {
|
||||
|
||||
private static final ContactAccessor instance = new ContactAccessor();
|
||||
|
||||
public static ContactAccessor getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ContactData getContactData(Context context, Uri uri) {
|
||||
String displayName = getNameFromContact(context, uri);
|
||||
long id = Long.parseLong(uri.getLastPathSegment());
|
||||
|
||||
ContactData contactData = new ContactData(id, displayName);
|
||||
|
||||
try (Cursor numberCursor = context.getContentResolver().query(Phone.CONTENT_URI,
|
||||
null,
|
||||
Phone.CONTACT_ID + " = ?",
|
||||
new String[] { contactData.id + "" },
|
||||
null))
|
||||
{
|
||||
while (numberCursor != null && numberCursor.moveToNext()) {
|
||||
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
|
||||
String label = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.LABEL));
|
||||
String number = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.NUMBER));
|
||||
String typeLabel = Phone.getTypeLabel(context.getResources(), type, label).toString();
|
||||
|
||||
contactData.numbers.add(new NumberData(typeLabel, number));
|
||||
}
|
||||
}
|
||||
|
||||
return contactData;
|
||||
}
|
||||
|
||||
private String getNameFromContact(Context context, Uri uri) {
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = context.getContentResolver().query(uri, new String[] { Contacts.DISPLAY_NAME }, null, null, null);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getString(0);
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public static class NumberData implements Parcelable {
|
||||
|
||||
public static final Parcelable.Creator<NumberData> CREATOR = new Parcelable.Creator<NumberData>() {
|
||||
public NumberData createFromParcel(Parcel in) {
|
||||
return new NumberData(in);
|
||||
}
|
||||
|
||||
public NumberData[] newArray(int size) {
|
||||
return new NumberData[size];
|
||||
}
|
||||
};
|
||||
|
||||
public final String number;
|
||||
public final String type;
|
||||
|
||||
public NumberData(String type, String number) {
|
||||
this.type = type;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
public NumberData(Parcel in) {
|
||||
number = in.readString();
|
||||
type = in.readString();
|
||||
}
|
||||
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(number);
|
||||
dest.writeString(type);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ContactData implements Parcelable {
|
||||
|
||||
public static final Parcelable.Creator<ContactData> CREATOR = new Parcelable.Creator<ContactData>() {
|
||||
public ContactData createFromParcel(Parcel in) {
|
||||
return new ContactData(in);
|
||||
}
|
||||
|
||||
public ContactData[] newArray(int size) {
|
||||
return new ContactData[size];
|
||||
}
|
||||
};
|
||||
|
||||
public final long id;
|
||||
public final String name;
|
||||
public final List<NumberData> numbers;
|
||||
|
||||
public ContactData(long id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.numbers = new LinkedList<NumberData>();
|
||||
}
|
||||
|
||||
public ContactData(Parcel in) {
|
||||
id = in.readLong();
|
||||
name = in.readString();
|
||||
numbers = new LinkedList<NumberData>();
|
||||
in.readTypedList(numbers, NumberData.CREATOR);
|
||||
}
|
||||
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeLong(id);
|
||||
dest.writeString(name);
|
||||
dest.writeTypedList(numbers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.contacts.SystemContactsRepository;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -70,11 +69,10 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
} else if (unknownSystemE164s.size() > 0) {
|
||||
List<Recipient> recipients = Stream.of(unknownSystemE164s)
|
||||
.filter(s -> s.startsWith("+"))
|
||||
.map(s -> Recipient.external(s))
|
||||
.filter(it -> it != null)
|
||||
.toList();
|
||||
List<Recipient> recipients = unknownSystemE164s.stream()
|
||||
.filter(s -> s.startsWith("+"))
|
||||
.map(s -> Recipient.external(s))
|
||||
.filter(it -> it != null).collect(Collectors.toList());
|
||||
|
||||
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown E164s, which are now " + recipients.size() + " recipients. Only syncing these specific contacts.");
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Copyright (C) 2011 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.contacts;
|
||||
|
||||
/**
|
||||
* Name and number tuple.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public class NameAndNumber {
|
||||
public String name;
|
||||
public String number;
|
||||
|
||||
public NameAndNumber(String name, String number) {
|
||||
this.name = name;
|
||||
this.number = number;
|
||||
}
|
||||
|
||||
public NameAndNumber() {}
|
||||
}
|
||||
@@ -200,7 +200,7 @@ class ContactSearchConfiguration private constructor(
|
||||
/**
|
||||
* Chat types that are displayed when creating a chat folder.
|
||||
*
|
||||
* Key: [ContactSearchKey.ChatType]
|
||||
* Key: [ContactSearchKey.ChatTypeSearchKey]
|
||||
* Data: [ContactSearchData.ChatTypeRow]
|
||||
* Model: [ContactSearchAdapter.ChatTypeModel]
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
@@ -75,9 +75,9 @@ class ContactFieldAdapter extends RecyclerView.Adapter<ContactFieldAdapter.Conta
|
||||
fields.add(new Field(avatar));
|
||||
}
|
||||
|
||||
fields.addAll(Stream.of(phoneNumbers).map(phone -> new Field(context, phone, locale)).toList());
|
||||
fields.addAll(Stream.of(emails).map(email -> new Field(context, email)).toList());
|
||||
fields.addAll(Stream.of(postalAddresses).map(address -> new Field(context, address)).toList());
|
||||
fields.addAll(phoneNumbers.stream().map(phone -> new Field(context, phone, locale)).collect(Collectors.toList()));
|
||||
fields.addAll(emails.stream().map(email -> new Field(context, email)).collect(Collectors.toList()));
|
||||
fields.addAll(postalAddresses.stream().map(address -> new Field(context, address)).collect(Collectors.toList()));
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.thoughtcrime.securesms.contactshare.Contact.Name;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
@@ -82,7 +82,7 @@ class ContactShareEditViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
private <E extends Selectable> List<E> trimSelectables(List<E> selectables) {
|
||||
return Stream.of(selectables).filter(Selectable::isSelected).toList();
|
||||
return selectables.stream().filter(Selectable::isSelected).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.i18n.phonenumbers.NumberParseException;
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
@@ -98,7 +97,7 @@ public final class ContactUtil {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Phone> mobileNumbers = Stream.of(contact.getPhoneNumbers()).filter(number -> number.getType() == Phone.Type.MOBILE).toList();
|
||||
List<Phone> mobileNumbers = contact.getPhoneNumbers().stream().filter(number -> number.getType() == Phone.Type.MOBILE).collect(Collectors.toList());
|
||||
if (mobileNumbers.size() > 0) {
|
||||
return mobileNumbers.get(0);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user