Compare commits

...

104 Commits

Author SHA1 Message Date
Greyson Parrelli aeb9054a63 Bump version to 8.6.2 2026-04-06 16:39:51 -04:00
Greyson Parrelli bb33945a93 Update translations and other static files. 2026-04-06 16:35:11 -04:00
Greyson Parrelli 3d2ceef47f Don't let the date validator starve the chat search. 2026-04-06 16:15:56 -04:00
Michelle Tang 892e6bd853 Fix OOM in collapse backfill job. 2026-04-06 12:15:35 -04:00
Alex Hart 78e1a407a6 Bump version to 8.6.1 2026-04-02 12:47:48 -03:00
Alex Hart 48d766ecff Update baseline profile. 2026-04-02 12:43:59 -03:00
Alex Hart d6d3226fcd Update translations and other static files. 2026-04-02 12:36:00 -03:00
Alex Hart ed4944f806 Write plaintext export to directory instead of zip, add notification content intent. 2026-04-02 12:15:14 -03:00
Alex Hart eb2dfb3fb6 Fix getViewLifecycleOwner crash in bubble view.post callback.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-02 11:33:23 -03:00
Alex Hart 265f71dff3 Surface error when local backup restore directory becomes inaccessible. 2026-04-02 11:27:22 -03:00
Michelle Tang 01d1769e4c Fix pinned message crash. 2026-04-02 10:26:58 -04:00
Alex Hart 97d099c7f1 Increment plaintext export config key. 2026-04-02 11:22:36 -03:00
Greyson Parrelli 0a957bc97c Fix crash when pressing volume buttons during active video recording. 2026-04-02 09:01:21 -04:00
Michelle Tang 5df7552506 Improve collapsed events with wallpapers. 2026-04-01 16:01:43 -04:00
Michelle Tang 75334abe0f Fix padding for collapsed events wrapping. 2026-04-01 14:58:37 -04:00
Michelle Tang 8524d20de5 Rotate collapse config. 2026-04-01 14:49:58 -04:00
Michelle Tang 495e2e043e Add various updates to collapsed events. 2026-04-01 10:49:37 -04:00
jeffrey-signal dec9eb613e Fix stale action cache save error and increase operations per run limit. 2026-04-01 10:07:03 -04:00
jeffrey-signal d6e7030dd0 Fix inactive pull request detection. 2026-03-31 17:54:13 -04:00
jeffrey-signal 6e43e931b2 Fix label applied to inactive issues. 2026-03-31 17:37:24 -04:00
Alex Hart 430a55f89f Bump version to 8.6.0 2026-03-31 16:49:29 -03:00
Alex Hart d717aad03d Update baseline profile. 2026-03-31 16:44:59 -03:00
Alex Hart efd86ad2fc Update translations and other static files. 2026-03-31 16:26:33 -03:00
Alex Hart b284835545 Move local backup progress tracking to in-memory object. 2026-03-31 16:20:26 -03:00
Alex Hart 4dd30f4ec3 Fix deactivated node crash in call screen layout.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:26 -03:00
Alex Hart a48938f3d8 Replace Environment bool with a RemoteConfig value. 2026-03-31 16:20:26 -03:00
Alex Hart 01989ad6e7 Fix issue with 12byte IV on older android versions. 2026-03-31 16:20:26 -03:00
Greyson Parrelli f37f67c6c0 Show optimized media in the all media view. 2026-03-31 16:20:26 -03:00
Greyson Parrelli 36f7c60a99 Improve camera mixed mode handling and clean up dead code. 2026-03-31 16:20:26 -03:00
Alex Hart 3f067654d9 Add plaintext chat history export UI. 2026-03-31 16:20:26 -03:00
Michelle Tang 0ce3eab3cd Fix scroll state of collapsed events. 2026-03-31 16:20:26 -03:00
jeffrey-signal b0f7c36cc2 Add additional group terminate checks to message processing.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-03-31 16:20:26 -03:00
Alex Hart 966e208be5 Fix DB write connection starvation in InAppPaymentsBottomSheetDelegate. 2026-03-31 16:20:26 -03:00
Greyson Parrelli a80d353e04 Fix issue where contact permission prompt wasn't dismissed. 2026-03-31 16:20:26 -03:00
Greyson Parrelli 080fa88bfb Improve handling of validating unpopulated profile field. 2026-03-31 16:20:26 -03:00
Michelle Tang 172e3d129e Fix attachment service crash due to timeout. 2026-03-31 16:20:26 -03:00
Alex Hart 52d5947c0a Treat 409 as successful redemption for recurring donation.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:26 -03:00
Alex Hart 7334ebfce1 Fix NPE in canUserAccessUnifiedBackupDirectory when backup directory is null.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:26 -03:00
Alex Hart 2c98bbaf7e Fix back navigation stuck in conversation after activity recreation.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:26 -03:00
Greyson Parrelli 5a91dba56e Update website variant manifest. 2026-03-31 16:20:26 -03:00
Greyson Parrelli 535c5a1574 Fix compile error in benchmarks. 2026-03-31 16:20:26 -03:00
Alex Hart b61c54c0e2 Fix thread header margin not accounting for status bar insets.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:26 -03:00
Alex Hart 5ac5d45fc6 Skip blocked/missing-keys info overlay for the local participant.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:26 -03:00
jeffrey-signal 79ba929e70 Fix selected photo missing checkmark in media gallery. 2026-03-31 16:20:26 -03:00
Cody Henthorne 3e9146a6f5 Improve device transfer reliability. 2026-03-31 16:20:26 -03:00
Michelle Tang 0c4c280a50 Reduce how often KT is reset. 2026-03-31 16:20:26 -03:00
Cody Henthorne ebea499a5a Add horizontal padding to call participant header. 2026-03-31 16:20:26 -03:00
Cody Henthorne d6b39e9f0a Respect phone number sharing privacy in call participant sheet. 2026-03-31 16:20:25 -03:00
Michelle Tang 787eaee6a0 Bump to libsignal v0.90.0
Co-authored-by: Andrew <andrew@signal.org>
2026-03-31 16:20:25 -03:00
Michelle Tang 5ecb3d8832 Fix pluralization of strings. 2026-03-31 16:20:25 -03:00
Greyson Parrelli b2e8666c9f Avoid chaining in BackupMessagesJob. 2026-03-31 16:20:25 -03:00
Greyson Parrelli 8af41e4b2c Fix image sometimes not showing immediately after send. 2026-03-31 16:20:25 -03:00
jeffrey-signal 5eaf1000c8 Prevent hidden recipients from appearing in recent conversations. 2026-03-31 16:20:25 -03:00
Cody Henthorne 4ed6773983 Exclude long text attachments when duplicating for incoming edits.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:25 -03:00
Greyson Parrelli 0de0441f65 Assign remote key to locally-split long text attachments during backup import. 2026-03-31 16:20:25 -03:00
Alex Hart 9e1b4a9a8c Add horizontal padding to pre-join call status text.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:25 -03:00
Greyson Parrelli bf28b90e89 Fix volume key interference during camera video recording. 2026-03-31 16:20:25 -03:00
jeffrey-signal a0a962a94f Fix sender name clipping in text-only incoming messages.
Resolves signalapp/Signal-Android#14646
2026-03-31 16:20:25 -03:00
Cody Henthorne abe0b2ebca Fix Backups settings row not rendering as disabled when unregistered. 2026-03-31 16:20:25 -03:00
Greyson Parrelli 7b4fe7ff40 Fix IndexOutOfBoundsException in story viewer back press. 2026-03-31 16:20:25 -03:00
Alex Hart 1ba9793943 Guard bubble inset request against detached view.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:25 -03:00
Cody Henthorne 14d4228e86 Retry StorageSyncJob on all IOExceptions. 2026-03-31 16:20:25 -03:00
Greyson Parrelli 3d2c51c14b Filter out revisions with mismatched authors during backup export. 2026-03-31 16:20:25 -03:00
Cody Henthorne 72d75e9cd5 Fix stale display names in search results. 2026-03-31 16:20:25 -03:00
Cody Henthorne e125fa6bfb Fix deadlock when sending media to story and group chat simultaneously. 2026-03-31 16:20:25 -03:00
benny10ben 57574126bb Fix deadlock when sending photo from camera to new contact.
Fixes #14674
Closes #14679
2026-03-31 16:20:25 -03:00
Greyson Parrelli 833c81a99e Guard against detached fragment in media preview error handlers. 2026-03-31 16:20:25 -03:00
Alex Hart 5ca17dfe52 Revert "Allow split pane on medium width."
This reverts commit a3d677533e2550897c7b548cb5b0bca199ec4287.
2026-03-31 16:20:25 -03:00
Alex Hart 5e058bb655 Allow split pane on medium width. 2026-03-31 16:20:25 -03:00
Cody Henthorne ce87b50a07 Add create-and-upload to important attachment upload flows.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-31 16:20:25 -03:00
Michelle Tang 2ad14800d1 Only get collapsed timer state when necessary. 2026-03-31 16:20:25 -03:00
Greyson Parrelli f04a0533cb Update SignalService.proto to match shared one. 2026-03-31 16:20:25 -03:00
Greyson Parrelli 5ae51f844e Drop legacy field from provisioning and sync messages. 2026-03-31 16:20:25 -03:00
jeffrey-signal 4ce2c6ef73 Replace legacy probot stale app with a GitHub actions workflow. 2026-03-31 10:19:46 -04:00
Cody Henthorne 4442f26f53 Bump version to 8.5.1 2026-03-27 16:13:39 -04:00
Cody Henthorne 849fce5a89 Update baseline profile. 2026-03-27 16:01:14 -04:00
Cody Henthorne 482fce6a25 Update translations and other static files. 2026-03-27 16:01:13 -04:00
Cody Henthorne e7e69ab064 Update group terminate strings. 2026-03-27 15:38:22 -04:00
Greyson Parrelli 4b768419da Prevent media gallery tabs from compressing text in some locales. 2026-03-27 15:10:00 -04:00
Greyson Parrelli 2cca01d30f Temporarily disable individual collisions. 2026-03-27 14:38:27 -04:00
Greyson Parrelli e0c69dc485 Fix images not showing up in message details. 2026-03-27 14:31:50 -04:00
Greyson Parrelli 1dd79efdb2 Fix select-all in the all media view. 2026-03-27 14:13:49 -04:00
Cody Henthorne dbb3c8def9 Fall back to next challenge when push challenge fails during registration.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-27 13:40:50 -04:00
Cody Henthorne 562185f46d Insert terminate event when restoring ended group from storage service. 2026-03-27 13:38:14 -04:00
Alex Hart f6c7c6de73 Fix local archive progression reporting in notification. 2026-03-27 12:38:36 -03:00
Greyson Parrelli 1ca3a9ca73 Fix unpin sync messages for 1:1 conversations. 2026-03-27 11:23:46 -04:00
jeffrey-signal c76c3f65f2 Fix notification reply avatar showing note to self instead of profile photo. 2026-03-27 11:21:14 -04:00
jeffrey-signal 59c27797d6 Fix backup validation error when a group member has a label emoji value without a label string. 2026-03-27 10:15:58 -04:00
Greyson Parrelli c5c720b1c9 Enforce length limits on link preview fields. 2026-03-26 15:57:36 -04:00
Greyson Parrelli caa09c82d0 Fix some APNGs not playing in new renderer. 2026-03-26 15:47:03 -04:00
Greyson Parrelli d45f80f25d Improve APNG validation in new APNG renderer. 2026-03-26 15:39:03 -04:00
Greyson Parrelli 6a248f617a Include links in the "All" tab of media overview. 2026-03-26 15:24:56 -04:00
Greyson Parrelli 2959e05ea7 Fix link multi-selection in media overview. 2026-03-26 15:24:56 -04:00
Cody Henthorne 17faf56388 Drop all messages sent to a terminated group. 2026-03-26 15:22:20 -04:00
Alex Hart f533ad1533 Update copy for backup education screen. 2026-03-26 16:15:05 -03:00
Alex Hart 25452fefa5 Retry canWrite() check when opening backup directory for writing.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-26 14:54:10 -03:00
Cody Henthorne 9702728c19 Gate poll, pin, and reaction UX in terminated groups. 2026-03-26 13:26:16 -04:00
Cody Henthorne 43f19d14d8 Add additional checks for terminated groups during send flows. 2026-03-26 11:59:11 -04:00
Alex Hart 467c154ea6 Utilize enqueue job for spawning attachment restore jobs. 2026-03-26 12:41:41 -03:00
Alex Hart d72c742ab6 Remove hint file. 2026-03-26 12:35:34 -03:00
Alex Hart 567bf0facc Add Last Backup help text. 2026-03-26 12:30:59 -03:00
Alex Hart d5329d0794 Support directly selecting signalbackup. 2026-03-26 12:25:37 -03:00
Alex Hart ff04e5c5c3 Ensure metadata is available when writing chat style attachments. 2026-03-26 10:51:51 -03:00
Alex Hart e529fbd1bc Suppress megaphone for upgrade path. 2026-03-26 09:41:47 -03:00
291 changed files with 13794 additions and 8919 deletions
-23
View File
@@ -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
View 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
+2 -2
View File
@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1671
val canonicalVersionName = "8.5.0"
val canonicalVersionCode = 1675
val canonicalVersionName = "8.6.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -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)
}
}
@@ -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
}
}
}
@@ -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()
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -214,7 +214,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)
@@ -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);
}
}
@@ -365,9 +365,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
if (onRefreshListener != null) {
onRefreshListener.onRefresh();
}
contactSearchMediator.refresh();
}
@Override
@@ -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,7 +161,7 @@ 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.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
@@ -342,6 +344,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(
@@ -1125,7 +1140,7 @@ class MainActivity :
}
}
if (CameraXUtil.isSupported()) {
if (CameraXRemoteConfig.isSupported()) {
onGranted()
} else {
Permissions.with(this@MainActivity)
@@ -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)
@@ -46,9 +46,9 @@ sealed interface FallbackAvatar {
fun getIconBySize(size: Size): Int
/**
* Local user
* Note to Self / local user
*/
data class Local(override val color: AvatarColor) : Resource {
data class NoteToSelf(override val color: AvatarColor) : Resource {
override fun getIconBySize(size: Size): Int {
return when (size) {
Size.SMALL -> R.drawable.symbol_note_compact_16
@@ -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
@@ -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
}
}
@@ -194,6 +194,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
@@ -593,14 +589,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 +1637,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 +1683,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 +1720,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 +1740,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 +2191,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 +2612,9 @@ class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMed
)
}
}
data class UploadedThumbnailInfo(
val cdnNumber: Int,
val remoteLocation: String,
val size: Long
)
@@ -1741,19 +1741,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
@@ -133,7 +133,7 @@ private fun DecryptedMember.toRemote(): Group.Member {
userId = aciBytes,
role = role.toRemote(),
joinedAtVersion = joinedAtRevision,
labelEmoji = labelEmoji,
labelEmoji = if (labelString.isNotBlank()) labelEmoji else "",
labelString = labelString
)
}
@@ -472,6 +472,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 +512,7 @@ class ChatItemArchiveImporter(
if (longTextAttachment != null) {
attachmentMap[longTextAttachment]?.let { attachmentId ->
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
}
}
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.local
import android.content.Context
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -17,6 +18,8 @@ import org.signal.archive.local.ArchivedFilesReader
import org.signal.core.models.backup.MediaName
import org.signal.core.util.Stopwatch
import org.signal.core.util.androidx.DocumentFileInfo
import org.signal.core.util.androidx.DocumentFileUtil
import org.signal.core.util.androidx.DocumentFileUtil.OperationResult
import org.signal.core.util.androidx.DocumentFileUtil.delete
import org.signal.core.util.androidx.DocumentFileUtil.hasFile
import org.signal.core.util.androidx.DocumentFileUtil.inputStream
@@ -26,7 +29,6 @@ import org.signal.core.util.androidx.DocumentFileUtil.newFile
import org.signal.core.util.androidx.DocumentFileUtil.outputStream
import org.signal.core.util.androidx.DocumentFileUtil.renameTo
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import java.io.File
import java.io.IOException
import java.io.InputStream
@@ -59,9 +61,18 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
* Should likely only be called on API29+
*/
fun fromUri(context: Context, uri: Uri): ArchiveFileSystem? {
val root = DocumentFile.fromTreeUri(context, uri)
val root = DocumentFile.fromTreeUri(context, uri) ?: return null
if (root == null || !root.canWrite()) {
val result = DocumentFileUtil.retryDocumentFileOperation<Unit> { attempt, maxAttempts ->
Log.d(TAG, "canWrite() check attempt ${attempt + 1}/$maxAttempts")
if (root.canWrite()) {
OperationResult.Success(true)
} else {
OperationResult.Retry
}
}
if (!result.isSuccess()) {
return null
}
@@ -77,15 +88,28 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
fun openForRestore(context: Context, uri: Uri): ArchiveFileSystem? {
val root = DocumentFile.fromTreeUri(context, uri) ?: return null
if (!root.canRead()) return null
if (root.findFile(MAIN_DIRECTORY_NAME) == null) return null
return openForRestore(context, root)
}
@VisibleForTesting
fun openForRestore(context: Context, root: DocumentFile): ArchiveFileSystem? {
if (root.findFile(MAIN_DIRECTORY_NAME) == null && !looksLikeSignalBackupsDirectory(root)) return null
return try {
ArchiveFileSystem(context, root, readOnly = true)
} catch (e: IOException) {
Log.w(TAG, "Unable to open backup directory for restore: $uri", e)
Log.w(TAG, "Unable to open backup directory for restore", e)
null
}
}
/**
* Returns true if [dir] appears to be a SignalBackups directory based on its name and
* expected internal structure (presence of the "files" subdirectory).
*/
private fun looksLikeSignalBackupsDirectory(dir: DocumentFile): Boolean {
return dir.name == MAIN_DIRECTORY_NAME && dir.findFile("files") != null
}
/**
* Attempt to create an [ArchiveFileSystem] from a regular [File].
*
@@ -105,22 +129,31 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
/** File access to shared super-set of archive related files (e.g., media + attachments) */
val filesFileSystem: FilesFileSystem
/**
* True if this file system was opened directly from the SignalBackups directory itself (rather than its parent).
* In this case, the URI cannot be reused as a backup destination since we lack access to the parent directory.
*/
val isRootedAtSignalBackups: Boolean
init {
if (readOnly) {
signalBackups = root.findFile(MAIN_DIRECTORY_NAME) ?: throw IOException("SignalBackups directory not found in $root")
val child = root.findFile(MAIN_DIRECTORY_NAME)
if (child != null) {
signalBackups = child
isRootedAtSignalBackups = false
} else if (looksLikeSignalBackupsDirectory(root)) {
signalBackups = root
isRootedAtSignalBackups = true
} else {
throw IOException("SignalBackups directory not found in $root")
}
val filesDirectory = signalBackups.findFile("files") ?: throw IOException("files directory not found in $signalBackups")
filesFileSystem = FilesFileSystem(context, filesDirectory, readOnly = true)
} else {
isRootedAtSignalBackups = false
signalBackups = root.mkdirp(MAIN_DIRECTORY_NAME) ?: throw IOException("Unable to create main backups directory")
val filesDirectory = signalBackups.mkdirp("files") ?: throw IOException("Unable to create files directory")
filesFileSystem = FilesFileSystem(context, filesDirectory)
val hintFileName = context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_name)
if (!root.hasFile(hintFileName)) {
root.createFile("text/plain", hintFileName)
?.outputStream(context)
?.use { out -> out.write(context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_body).toByteArray()) }
}
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -142,7 +142,12 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
composable(route = MessageBackupsStage.Route.BACKUP_KEY_EDUCATION.name) {
MessageBackupsKeyEducationScreen(
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage
onNextClick = viewModel::goToNextStage,
mode = if (SignalStore.backup.newLocalBackupsEnabled) {
MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED
} else {
MessageBackupsKeyEducationScreenMode.DEFAULT
}
)
}
@@ -50,9 +50,9 @@ import org.signal.core.ui.R as CoreUiR
enum class MessageBackupsKeyEducationScreenMode {
/**
* Displayed when the user is enabling remote backups and does not have unified local backups enabled
* Displayed when the user is enabling remote backups, or local backups without remote enabled.
*/
REMOTE_BACKUP_WITH_LOCAL_DISABLED,
DEFAULT,
/**
* Displayed when the user is upgrading legacy to unified local backup
@@ -60,9 +60,14 @@ enum class MessageBackupsKeyEducationScreenMode {
LOCAL_BACKUP_UPGRADE,
/**
* Displayed when the user has unified local backup and is enabling remote backups
* Displayed when the user has remote backups enabled and is enabling local backups
*/
REMOTE_BACKUP_WITH_LOCAL_ENABLED
LOCAL_WITH_REMOTE_ENABLED,
/**
* Displayed when the user has local backups enabled and is enabling remote backups
*/
REMOTE_WITH_LOCAL_ENABLED
}
/**
@@ -72,7 +77,7 @@ enum class MessageBackupsKeyEducationScreenMode {
fun MessageBackupsKeyEducationScreen(
onNavigationClick: () -> Unit = {},
onNextClick: () -> Unit = {},
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.DEFAULT
) {
val scrollState = rememberScrollState()
@@ -105,14 +110,19 @@ fun MessageBackupsKeyEducationScreen(
)
when (mode) {
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> {
MessageBackupsKeyEducationScreenMode.DEFAULT -> {
RemoteBackupWithLocalDisabledInfo()
}
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> {
LocalBackupUpgradeInfo()
}
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> {
MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED -> {
LocalBackupWithRemoteEnabledInfo()
}
MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED -> {
RemoteBackupWithLocalEnabledInfo()
}
}
@@ -145,9 +155,8 @@ fun MessageBackupsKeyEducationScreen(
@Composable
private fun getTitleText(mode: MessageBackupsKeyEducationScreenMode): String {
return when (mode) {
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key)
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_new_recovery_key)
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_recovery_key)
else -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_recovery_key)
}
}
@@ -176,6 +185,31 @@ private fun LocalBackupUpgradeInfo() {
}
}
@Composable
private fun LocalBackupWithRemoteEnabledInfo() {
val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description)
val boldText = stringResource(R.string.MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold)
DescriptionText(
normalText = normalText,
boldText = boldText
)
UseThisKeyToContainer {
UseThisKeyToRow(
icon = ImageVector.vectorResource(R.drawable.symbol_folder_24),
text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_on_device_backup)
)
Spacer(modifier = Modifier.padding(vertical = 16.dp))
UseThisKeyToRow(
icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_backup_24),
text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_your_signal_secure_backup)
)
}
}
@Composable
private fun RemoteBackupWithLocalEnabledInfo() {
val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description)
@@ -313,10 +347,10 @@ private fun InfoRow(@DrawableRes iconId: Int, @StringRes textId: Int) {
@DayNightPreviews
@Composable
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalDisabledPreview() {
private fun MessageBackupsKeyEducationScreenDefaultPreview() {
Previews.Preview {
MessageBackupsKeyEducationScreen(
mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
mode = MessageBackupsKeyEducationScreenMode.DEFAULT
)
}
}
@@ -333,10 +367,20 @@ private fun MessageBackupsKeyEducationScreenLocalBackupUpgradePreview() {
@DayNightPreviews
@Composable
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalEnabledPreview() {
private fun MessageBackupsKeyEducationScreenLocalBackupWithRemoteEnabledPreview() {
Previews.Preview {
MessageBackupsKeyEducationScreen(
mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED
mode = MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED
)
}
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalEnabledPreview() {
Previews.Preview {
MessageBackupsKeyEducationScreen(
mode = MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED
)
}
}
@@ -257,7 +257,7 @@ private fun Wallpaper.LinearGradient.toRemoteWallpaperPreset(): ChatStyle.Wallpa
private fun Wallpaper.File.toFilePointer(db: SignalDatabase, backupMode: BackupMode): FilePointer? {
val attachmentId: AttachmentId = UriUtil.parseOrNull(this.uri)?.let { PartUriParser(it).partId } ?: return null
val attachment = db.attachmentTable.getAttachment(attachmentId)
val attachment = db.attachmentTable.getAttachmentWithMetadata(attachmentId)
return attachment?.toRemoteFilePointer(backupMode = backupMode)
}
@@ -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);
}
@@ -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)
},
@@ -110,7 +110,13 @@ class LocalBackupsFragment : ComposeFragment() {
MessageBackupsKeyEducationScreen(
onNavigationClick = { backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() },
onNextClick = { backstack.add(LocalBackupsNavKey.RECORD_RECOVERY_KEY) },
mode = MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
mode = if (args.triggerUpdateFlow) {
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
} else if (SignalStore.backup.areBackupsEnabled) {
MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED
} else {
MessageBackupsKeyEducationScreenMode.DEFAULT
}
)
}
@@ -141,12 +141,14 @@ internal fun LocalBackupsSettingsScreen(
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = state.lastBackupLabel.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
if (state.lastBackupLabel != null) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_last_backup_s, state.lastBackupLabel),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
},
onClick = callback::onCreateBackupClick
@@ -270,7 +272,7 @@ private fun LocalBackupsSettingsEnabledIdlePreview() {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
lastBackupLabel = "1 hour ago",
folderDisplayName = "/storage/emulated/0/Signal/Backups",
scheduleTimeLabel = "1:00 AM",
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
@@ -287,7 +289,7 @@ private fun LocalBackupsSettingsEnabledExportingIndeterminatePreview() {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
lastBackupLabel = "1 hour ago",
folderDisplayName = "/storage/emulated/0/Signal/Backups",
scheduleTimeLabel = "1:00 AM",
progress = LocalBackupCreationProgress(
@@ -306,7 +308,7 @@ private fun LocalBackupsSettingsEnabledExportingMessagesPreview() {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
lastBackupLabel = "1 hour ago",
folderDisplayName = "/storage/emulated/0/Signal/Backups",
scheduleTimeLabel = "1:00 AM",
progress = LocalBackupCreationProgress(
@@ -329,7 +331,7 @@ private fun LocalBackupsSettingsEnabledTransferringPreview() {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
lastBackupLabel = "1 hour ago",
folderDisplayName = "/storage/emulated/0/Signal/Backups",
scheduleTimeLabel = "1:00 AM",
progress = LocalBackupCreationProgress(
@@ -352,7 +354,7 @@ private fun LocalBackupsSettingsEnabledNonLegacyPreview() {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
lastBackupLabel = "1 hour ago",
folderDisplayName = "Signal Backups",
scheduleTimeLabel = "1:00 AM",
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
@@ -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) {
@@ -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
@@ -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()
@@ -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
}
@@ -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)
)
}
}
@@ -310,7 +310,7 @@ public class ConversationMessage {
long collapsedExpirationInMs = 0;
if (CollapsedState.isHead(messageRecord.getCollapsedState())) {
collapsedSize = SignalDatabase.messages().getCollapsedCount(messageRecord.getId());
if (CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras()) == CollapsibleEvents.CollapsibleType.DISAPPEARING_TIMER) {
if (CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras()) == CollapsibleEvents.CollapsibleType.DISAPPEARING_TIMER && collapsedSize > 1) {
collapsedExpirationInMs = SignalDatabase.messages().getDisappearingTimerStateForCollapsedSet(messageRecord.getId());
}
}
@@ -32,6 +32,7 @@ import com.bumptech.glide.RequestManager;
import com.google.android.material.button.MaterialButton;
import com.google.common.collect.Sets;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.ListenableFuture;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BindableConversationItem;
@@ -863,40 +864,46 @@ public final class ConversationUpdateItem extends FrameLayout
}
private void presentCollapsedHead(CollapsedState collapsedState) {
CollapsibleEvents.CollapsibleType collapsibleType = CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras());
if (CollapsedState.isHead(collapsedState) && conversationMessage.getCollapsedSize() > 1 && collapsibleType != null) {
SpannableStringBuilder text = new SpannableStringBuilder()
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, getCollapsibleSymbol(collapsibleType), org.signal.core.ui.R.color.signal_colorOnSurfaceVariant))
.append(" ")
.append(getCollapsibleString(collapsibleType))
.append(" ")
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, collapsedState == CollapsedState.HEAD_EXPANDED ? SignalSymbols.Glyph.CHEVRON_UP : SignalSymbols.Glyph.CHEVRON_DOWN, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant));
collapsedButton.setText(text);
collapsedButton.setOnClickListener(v -> {
if (eventListener != null) {
if (CollapsedState.isCollapsed(collapsedState)) {
eventListener.onExpandEvents(conversationMessage.getMessageRecord().getId());
} else if (!anyCollapsibleChildrenSelected()) {
eventListener.onCollapseEvents(conversationMessage.getMessageRecord().getId());
}
} else {
passthroughClickListener.onClick(v);
}
});
collapsedButton.setVisibility(VISIBLE);
} else {
if (!conversationMessage.isActiveCollapsibleHead()) {
collapsedButton.setVisibility(GONE);
} else {
CollapsibleEvents.CollapsibleType collapsibleType = CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras());
if (collapsibleType != null) {
SpannableStringBuilder text = new SpannableStringBuilder()
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, getCollapsibleSymbol(collapsibleType), org.signal.core.ui.R.color.signal_colorOnSurfaceVariant))
.append(" ")
.append(getCollapsibleString(collapsibleType))
.append(" ")
.append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, collapsedState == CollapsedState.HEAD_EXPANDED ? SignalSymbols.Glyph.CHEVRON_UP : SignalSymbols.Glyph.CHEVRON_DOWN, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant));
collapsedButton.setText(text);
collapsedButton.setOnClickListener(v -> {
if (eventListener != null) {
if (CollapsedState.isCollapsed(collapsedState)) {
eventListener.onExpandEvents(conversationMessage.getMessageRecord().getId(), ConversationUpdateItem.this, conversationMessage.getCollapsedSize());
} else if (!anyCollapsibleChildrenSelected()) {
eventListener.onCollapseEvents(conversationMessage.getMessageRecord().getId(), ConversationUpdateItem.this, conversationMessage.getCollapsedSize());
}
} else {
passthroughClickListener.onClick(v);
}
});
ViewUtil.setBottomMargin(collapsedButton, (int) DimensionUnit.DP.toPixels(conversationMessage.isActiveCollapsedHead() ? 0 : 12));
collapsedButton.setVisibility(VISIBLE);
} else {
Log.w(TAG, "Found a message that is a collapsible head but does not have a collapsible type.");
collapsedButton.setVisibility(GONE);
}
}
}
private @NonNull String getCollapsibleString(CollapsibleEvents.CollapsibleType type) {
return switch (type) {
case CALL_EVENT -> getContext().getString(R.string.CollapsedEvent__call_event, conversationMessage.getCollapsedSize());
case CALL_EVENT -> getContext().getResources().getQuantityString(R.plurals.CollapsedEvent__call_event, conversationMessage.getCollapsedSize(), conversationMessage.getCollapsedSize());
case DISAPPEARING_TIMER -> {
String time = ExpirationUtil.getExpirationAbbreviatedDisplayValue(getContext(), (int) (conversationMessage.getCollapsedExpirationInMs() / 1000));
yield getContext().getString(R.string.CollapsedEvent__disappearing_timer, conversationMessage.getCollapsedSize(), time) ;
yield getContext().getResources().getQuantityString(R.plurals.CollapsedEvent__disappearing_timer, conversationMessage.getCollapsedSize(), conversationMessage.getCollapsedSize(), time) ;
}
case CHAT_UPDATE -> getContext().getString(conversationRecipient.isGroup() ? R.string.CollapsedEvent__group_update : R.string.CollapsedEvent__chat_update, conversationMessage.getCollapsedSize());
case CHAT_UPDATE -> getContext().getResources().getQuantityString(conversationRecipient.isGroup() ? R.plurals.CollapsedEvent__group_update : R.plurals.CollapsedEvent__chat_update, conversationMessage.getCollapsedSize(), conversationMessage.getCollapsedSize());
};
}
@@ -258,7 +258,7 @@ public final class MenuState {
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment && !hasPoll)
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
.shouldShowReactions(!conversationRecipient.isReleaseNotes() && !conversationRecipient.isInactiveGroup())
.shouldShowPaymentDetails(hasPayment)
.shouldShowPollTerminate(hasPollTerminate)
.shouldShowPinMessage(canPinMessage)
@@ -94,6 +94,6 @@ object EmptyConversationAdapterListener : ConversationAdapter.ItemClickListener
override fun onViewPollClicked(messageId: Long) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
override fun onViewPinnedMessage(messageId: Long) = Unit
override fun onExpandEvents(messageId: Long) = Unit
override fun onCollapseEvents(messageId: Long) = Unit
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
}
@@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationActivityResultCont
import org.thoughtcrime.securesms.giph.ui.GiphyActivity
import org.thoughtcrime.securesms.maps.PlacePickerActivity
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
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.recipients.RecipientId
import org.signal.core.ui.R as CoreUiR
@@ -76,7 +76,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat
}
fun launchCamera(recipientId: RecipientId, isReply: Boolean) {
if (CameraXUtil.isSupported()) {
if (CameraXRemoteConfig.isSupported()) {
cameraLauncher.launch(MediaSelectionInput(emptyList(), recipientId, null, isReply))
fragment.requireActivity().overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary)
} else {
@@ -387,6 +387,7 @@ class ConversationAdapterV2(
return
}
bindable.setParentScrolling(true)
bindable.bind(
lifecycleOwner,
model.conversationMessage,
@@ -404,6 +405,7 @@ class ConversationAdapterV2(
colorizer,
displayMode
)
bindable.setParentScrolling(isParentInScroll)
}
}
@@ -415,6 +417,7 @@ class ConversationAdapterV2(
return
}
bindable.setParentScrolling(true)
bindable.bind(
lifecycleOwner,
model.conversationMessage,
@@ -432,6 +435,7 @@ class ConversationAdapterV2(
colorizer,
displayMode
)
bindable.setParentScrolling(isParentInScroll)
}
}
@@ -107,7 +107,7 @@ class ConversationBannerView @JvmOverloads constructor(
setBannerRecipients(requestReviewState.individualReviewState.target, requestReviewState.individualReviewState.firstDuplicate)
setOnClickListener { listener?.onRequestReviewIndividual(requestReviewState.individualReviewState.target.id) }
} else if (requestReviewState.groupReviewState != null) {
setBannerMessage(context.getString(R.string.ConversationFragment__d_group_members_have_the_same_name, requestReviewState.groupReviewState.count))
setBannerMessage(context.resources.getQuantityString(R.plurals.ConversationFragment__d_group_members_have_the_same_name, requestReviewState.groupReviewState.count, requestReviewState.groupReviewState.count))
setBannerRecipients(requestReviewState.groupReviewState.target, requestReviewState.groupReviewState.firstDuplicate)
setOnClickListener { listener?.onReviewGroupMembers(requestReviewState.groupReviewState.groupId) }
}
@@ -48,12 +48,12 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnPreDraw
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
@@ -587,6 +587,7 @@ class ConversationFragment :
private var progressDialog: ProgressCardDialogFragment? = null
private var firstPinRender: Boolean = true
private var skipNextBackPressHandling: Boolean = false
private var collapsibleEventScrollPosition: CollapsibleEventScrollPosition? = null
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
@@ -650,13 +651,16 @@ class ConversationFragment :
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.resetBackPressedState()
binding.toolbar.isBackInvokedCallbackEnabled = false
binding.root.setUseWindowTypes(args.conversationScreenType == ConversationScreenType.NORMAL && !resources.getWindowSizeClass().isSplitPane())
if (args.conversationScreenType == ConversationScreenType.BUBBLE) {
binding.root.setNavigationBarInsetOverride(0)
view.post {
ViewCompat.requestApplyInsets(binding.root)
binding.root.requestLayout()
if (isAdded && this@ConversationFragment.view != null) {
ViewCompat.requestApplyInsets(binding.root)
binding.root.requestLayout()
}
}
}
@@ -711,7 +715,6 @@ class ConversationFragment :
startActivity(MemberLabelActivity.createIntent(requireContext(), groupId))
}
ToolbarDependentMarginListener(binding.toolbar)
initializeMediaKeyboard()
binding.conversationVideoContainer.setClipToOutline(true)
@@ -724,8 +727,15 @@ class ConversationFragment :
viewModel.onChatBoundsChanged(Rect(left, top, right, bottom))
}
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, _ ->
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
binding.conversationItemRecycler.padding(top = bottom)
if (bottom != oldBottom && ::threadHeaderMarginDecoration.isInitialized) {
val newMargin = bottom + 16.dp
if (threadHeaderMarginDecoration.toolbarMargin != newMargin) {
threadHeaderMarginDecoration.toolbarMargin = newMargin
binding.conversationItemRecycler.invalidateItemDecorations()
}
}
}
binding.conversationItemRecycler.addItemDecoration(ChatColorsDrawable.ChatColorsItemDecoration)
@@ -804,7 +814,6 @@ class ConversationFragment :
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ToolbarDependentMarginListener(binding.toolbar)
inlineQueryController.onWindowSizeClassChanged(resources.getWindowSizeClass())
}
@@ -1035,7 +1044,14 @@ class ConversationFragment :
state.isInActionMode -> finishActionMode()
state.isMediaKeyboardShowing -> container.hideInput()
state.isMediaKeyboardShowing -> {
if (container.isInputShowing) {
container.hideInput()
} else {
Log.d(TAG, "handleBackPressed() - media keyboard state was stale, clearing")
viewModel.setIsMediaKeyboardShowing(false)
}
}
else -> {
// State has changed since the back handler was enabled. Let the back press proceed
@@ -1132,6 +1148,13 @@ class ConversationFragment :
doAfterFirstRender()
}
}
if (collapsibleEventScrollPosition != null) {
val scrollState = collapsibleEventScrollPosition!!
val offset = binding.conversationItemRecycler.height - scrollState.top - scrollState.height
layoutManager.scrollToPositionWithOffset(scrollState.position, offset)
collapsibleEventScrollPosition = null
}
}
})
}
@@ -2173,6 +2196,9 @@ class ConversationFragment :
)
threadHeaderMarginDecoration = ThreadHeaderMarginDecoration()
val statusBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
threadHeaderMarginDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
binding.conversationItemRecycler.addItemDecoration(threadHeaderMarginDecoration)
conversationItemDecorations = ConversationItemDecorations(hasWallpaper = args.wallpaper != null)
@@ -3003,6 +3029,14 @@ class ConversationFragment :
return
}
if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
return
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.Poll__end_poll_title))
.setMessage(getString(R.string.Poll__end_poll_body))
@@ -3434,7 +3468,7 @@ class ConversationFragment :
context ?: return
val reactionsTag = "REACTIONS"
if (parentFragmentManager.findFragmentByTag(reactionsTag) == null) {
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(childFragmentManager, reactionsTag)
ReactionsBottomSheetDialogFragment.create(messageId, isMms, conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true).show(childFragmentManager, reactionsTag)
}
}
@@ -3590,6 +3624,13 @@ class ConversationFragment :
}
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
return
}
viewModel.toggleVote(poll, pollOption, isChecked)
}
@@ -3779,11 +3820,19 @@ class ConversationFragment :
}
}
override fun onExpandEvents(messageId: Long) {
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) {
val position = binding.conversationItemRecycler.getChildAdapterPosition(itemView)
if (position != RecyclerView.NO_POSITION && position != 0) {
collapsibleEventScrollPosition = CollapsibleEventScrollPosition(position = position + (collapsedSize - 1), top = itemView.top, height = itemView.height)
}
viewModel.onExpandEvents(messageId)
}
override fun onCollapseEvents(messageId: Long) {
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) {
val position = binding.conversationItemRecycler.getChildAdapterPosition(itemView)
if (position != RecyclerView.NO_POSITION) {
collapsibleEventScrollPosition = CollapsibleEventScrollPosition(position = position - (collapsedSize - 1), top = itemView.top, height = itemView.height)
}
viewModel.onCollapseEvents(messageId)
}
@@ -5136,25 +5185,6 @@ class ConversationFragment :
}
}
private inner class ToolbarDependentMarginListener(private val toolbar: Toolbar) : ViewTreeObserver.OnGlobalLayoutListener {
init {
toolbar.viewTreeObserver.addOnGlobalLayoutListener(this)
}
override fun onGlobalLayout() {
if (!isAdded || view == null) {
return
}
val rect = Rect()
toolbar.getGlobalVisibleRect(rect)
threadHeaderMarginDecoration.toolbarMargin = rect.bottom + 16.dp
binding.conversationItemRecycler.invalidateItemDecorations()
toolbar.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
}
private inner class ThreadHeaderMarginDecoration : RecyclerView.ItemDecoration() {
var toolbarMargin: Int = 0
@@ -5227,4 +5257,9 @@ class ConversationFragment :
override fun onDoubleTapEditEducationSheetNext(conversationMessage: ConversationMessage) {
handleEditMessage(conversationMessage)
}
/**
* Tracks the scroll position so that after collapsing/expanding, we can restore it properly
*/
private data class CollapsibleEventScrollPosition(val position: Int, val top: Int, val height: Int)
}
@@ -155,7 +155,7 @@ class ConversationRepository(
metadata.threadSize
)
val config = PagingConfig.Builder().setPageSize(25)
.setBufferPages(2)
.setBufferPages(3)
.setStartIndex(max(metadata.getStartPosition(), 0))
.build()
@@ -221,6 +221,13 @@ class ConversationRepository(
if (threadRecipient.isPushV2Group && threadRecipient.groupId.getOrNull()?.isV2 != true) {
Log.w(TAG, "Missing group id")
emitter.tryOnError(Exception("Poll terminate failed"))
return@create
}
if (threadRecipient.isPushV2Group && !SignalDatabase.groups.isActive(threadRecipient.requireGroupId())) {
Log.w(TAG, "Cannot end poll in terminated or inactive group")
emitter.tryOnError(Exception("Poll terminate failed"))
return@create
}
val message = OutgoingMessage.pollTerminateMessage(
@@ -733,6 +733,10 @@ class ConversationViewModel(
}
}
fun resetBackPressedState() {
internalBackPressedState.value = BackPressedState()
}
fun toggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
val voteCount = if (isChecked) {
@@ -9,6 +9,7 @@ import com.google.android.material.datepicker.CalendarConstraints.DateValidator
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.core.util.LRUCache
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.SignalDatabase
import java.time.Instant
@@ -36,7 +37,7 @@ private typealias MessageDateLookup = (Collection<Long>) -> Map<Long, Boolean>
class JumpToDateValidator private constructor(
private val threadId: Long,
@IgnoredOnParcel private val messageExistanceLookup: MessageDateLookup = createDefaultLookup(threadId),
@IgnoredOnParcel private val executor: Executor = SignalExecutors.BOUNDED,
@IgnoredOnParcel private val executor: Executor,
private val zoneId: ZoneId = ZoneId.systemDefault()
) : DateValidator {
@@ -51,7 +52,7 @@ class JumpToDateValidator private constructor(
return JumpToDateValidator(
threadId = threadId,
messageExistanceLookup = createDefaultLookup(threadId),
executor = SignalExecutors.BOUNDED,
executor = SignalExecutors.newCachedSingleThreadExecutor("jump-to-date-validator", ThreadUtil.PRIORITY_BACKGROUND_THREAD),
zoneId = ZoneId.systemDefault()
).also {
it.performInitialPrefetch()
@@ -52,6 +52,7 @@ import org.signal.glide.compose.GlideImage
import org.signal.glide.decryptableuri.DecryptableUri
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.contactshare.ContactUtil
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.fonts.SignalSymbols
@@ -269,7 +270,7 @@ fun getMessageMetadata(conversationMessage: ConversationMessage): Triple<SignalS
} else if (message.isPoll()) {
Triple(SignalSymbols.Glyph.POLL, SpannableString(stringResource(R.string.Poll__poll_question, message.body)), false)
} else if (message.hasSharedContact()) {
Triple(SignalSymbols.Glyph.PERSON_CIRCLE, SpannableString(message.sharedContacts.first().name.givenName), false)
Triple(SignalSymbols.Glyph.PERSON_CIRCLE, SpannableString(ContactUtil.getDisplayName(message.sharedContacts.first())), false)
} else if (message.isPaymentNotification && message.payment != null) {
Triple(SignalSymbols.Glyph.CREDIT_CARD, SpannableString(message.payment!!.amount.toString(FormatterOptions.defaults())), false)
} else if (slide?.isVideoGif == true) {
@@ -73,7 +73,7 @@ class ConversationGroupViewModel(
fun canEditGroupInfo(): Boolean {
val memberLevel = _memberLevel.value ?: return true
return memberLevel.groupTableMemberLevel == GroupTable.MemberLevel.ADMINISTRATOR || memberLevel.allMembersCanEditGroupInfo
return groupRecordSnapshot?.isActive == true && (memberLevel.groupTableMemberLevel == GroupTable.MemberLevel.ADMINISTRATOR || memberLevel.allMembersCanEditGroupInfo)
}
fun blockJoinRequests(recipient: Recipient): Single<GroupBlockJoinRequestResult> {
@@ -7,6 +7,8 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
*/
object CollapsibleEvents {
const val MAX_SIZE = 50
@JvmStatic
fun isCollapsibleType(type: Long, messageExtras: MessageExtras?): Boolean {
return getCollapsibleType(type, messageExtras) != null
@@ -41,6 +43,18 @@ object CollapsibleEvents {
return CollapsibleType.CHAT_UPDATE
}
if (MessageTypes.isPinnedMessageUpdate(type)) {
return CollapsibleType.CHAT_UPDATE
}
if (MessageTypes.isPollTerminate(type)) {
return CollapsibleType.CHAT_UPDATE
}
if (MessageTypes.isChangeNumber(type)) {
return CollapsibleType.CHAT_UPDATE
}
return null
}
@@ -236,36 +236,35 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
* Retrieves all InAppPayment objects for donations that have been marked NOTIFIED = 0, and then marks them
* all as notified.
*/
fun consumeDonationPaymentsToNotifyUser(): List<InAppPayment> {
return writableDatabase.withinTransaction { db ->
val payments = db.select()
.from(TABLE_NAME)
.where("$NOTIFIED = ? AND $TYPE != ?", 0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
.run()
.readToList(mapper = { InAppPayment.deserialize(it) })
db.update(TABLE_NAME).values(NOTIFIED to 1)
.where("$TYPE != ?", InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
.run()
payments
}
}
fun consumeDonationPaymentsToNotifyUser(): List<InAppPayment> = consumePaymentsToNotifyUser(
where = "$NOTIFIED = ? AND $TYPE != ?",
args = arrayOf(0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
)
/**
* Retrieves all InAppPayment objects for backups that have been marked NOTIFIED = 0, and then marks them
* all as notified.
*/
fun consumeBackupPaymentsToNotifyUser(): List<InAppPayment> {
fun consumeBackupPaymentsToNotifyUser(): List<InAppPayment> = consumePaymentsToNotifyUser(
where = "$NOTIFIED = ? AND $TYPE = ?",
args = arrayOf(0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
)
private fun consumePaymentsToNotifyUser(where: String, args: Array<Any>): List<InAppPayment> {
val hasUnnotified = readableDatabase.exists(TABLE_NAME)
.where(where, *args)
.run()
if (!hasUnnotified) return emptyList()
return writableDatabase.withinTransaction { db ->
val payments = db.select()
.from(TABLE_NAME)
.where("$NOTIFIED = ? AND $TYPE = ?", 0, InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
.where(where, *args)
.run()
.readToList(mapper = { InAppPayment.deserialize(it) })
db.update(TABLE_NAME).values(NOTIFIED to 1)
.where("$TYPE = ?", InAppPaymentType.serialize(InAppPaymentType.RECURRING_BACKUP))
.where(where, *args)
.run()
payments
@@ -20,65 +20,67 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
private val TAG = Log.tag(MediaTable::class)
const val ALL_THREADS = -1
private const val THREAD_RECIPIENT_ID = "THREAD_RECIPIENT_ID"
private const val MEDIA_MESSAGE_ID = "media_message_id"
private val BASE_MEDIA_QUERY = """
SELECT
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ID} AS ${AttachmentTable.ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_TYPE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFER_STATE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_SIZE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.FILE_NAME},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_FILE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_FILE},
SELECT
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ID} AS ${AttachmentTable.ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_TYPE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFER_STATE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_SIZE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.FILE_NAME},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_FILE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_FILE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CDN_NUMBER},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_LOCATION},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_KEY},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_DIGEST},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_DIGEST},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.FAST_PREFLIGHT_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VOICE_NOTE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BORDERLESS},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VIDEO_GIF},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.WIDTH},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.HEIGHT},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VOICE_NOTE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BORDERLESS},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.VIDEO_GIF},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.WIDTH},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.HEIGHT},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE_TARGET_CONTENT_TYPE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_KEY},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_EMOJI},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BLUR_HASH},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFORM_PROPERTIES},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DISPLAY_ORDER},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CAPTION},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_KEY},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_ID},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_EMOJI},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.BLUR_HASH},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFORM_PROPERTIES},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DISPLAY_ORDER},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CAPTION},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH_END},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_CDN},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_RESTORE_STATE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_TRANSFER_STATE},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.ATTACHMENT_UUID},
${MessageTable.TABLE_NAME}.${MessageTable.TYPE},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SERVER},
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID},
${MessageTable.TABLE_NAME}.${MessageTable.TYPE},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SERVER},
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID},
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID},
${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} as $THREAD_RECIPIENT_ID,
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS}
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} as $MEDIA_MESSAGE_ID
FROM
${AttachmentTable.TABLE_NAME} __INDEX_HINT__
LEFT JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
LEFT JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} = ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}
WHERE
__THREAD_FILTER__ AND
(%s) AND
${MessageTable.VIEW_ONCE} = 0 AND
(%s) AND
${MessageTable.VIEW_ONCE} = 0 AND
${MessageTable.STORY_TYPE} = 0 AND
${MessageTable.LATEST_REVISION_ID} IS NULL AND
${AttachmentTable.QUOTE} = 0 AND
${AttachmentTable.STICKER_PACK_ID} IS NULL AND
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} > 0 AND
${MessageTable.LATEST_REVISION_ID} IS NULL AND
${AttachmentTable.QUOTE} = 0 AND
${AttachmentTable.STICKER_PACK_ID} IS NULL AND
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} > 0 AND
$THREAD_RECIPIENT_ID > 0
"""
@@ -107,8 +109,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
private val GALLERY_MEDIA_QUERY_INCLUDING_TEMP_VIDEOS = String.format(
BASE_MEDIA_QUERY,
"""
(${AttachmentTable.DATA_FILE} IS NOT NULL OR (${AttachmentTable.CONTENT_TYPE} LIKE 'video/%' AND ${AttachmentTable.REMOTE_INCREMENTAL_DIGEST} IS NOT NULL) OR (${AttachmentTable.THUMBNAIL_FILE} IS NOT NULL)) AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/svg%' AND
(${AttachmentTable.DATA_FILE} IS NOT NULL OR (${AttachmentTable.CONTENT_TYPE} LIKE 'video/%' AND ${AttachmentTable.REMOTE_INCREMENTAL_DIGEST} IS NOT NULL) OR ${AttachmentTable.THUMBNAIL_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/svg%' AND
(${AttachmentTable.CONTENT_TYPE} LIKE 'image/%' OR ${AttachmentTable.CONTENT_TYPE} LIKE 'video/%') AND
${MessageTable.LINK_PREVIEWS} IS NULL AND
${MessageTable.SCHEDULED_DATE} < 0
@@ -118,7 +120,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
private val AUDIO_MEDIA_QUERY = String.format(
BASE_MEDIA_QUERY,
"""
${AttachmentTable.DATA_FILE} IS NOT NULL AND
(${AttachmentTable.DATA_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
${AttachmentTable.CONTENT_TYPE} LIKE 'audio/%' AND
${MessageTable.SCHEDULED_DATE} < 0
"""
@@ -127,7 +129,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
private val ALL_MEDIA_QUERY = String.format(
BASE_MEDIA_QUERY,
"""
${AttachmentTable.DATA_FILE} IS NOT NULL AND
(${AttachmentTable.DATA_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'text/x-signal-plain' AND
${MessageTable.LINK_PREVIEWS} IS NULL AND
${MessageTable.SCHEDULED_DATE} < 0
@@ -179,7 +181,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID},
${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID},
${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} as $THREAD_RECIPIENT_ID,
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS}
${MessageTable.TABLE_NAME}.${MessageTable.LINK_PREVIEWS},
${MessageTable.TABLE_NAME}.${MessageTable.ID} as $MEDIA_MESSAGE_ID
FROM
${MessageTable.TABLE_NAME}
LEFT JOIN ${AttachmentTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
@@ -200,13 +203,13 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
private val DOCUMENT_MEDIA_QUERY = String.format(
BASE_MEDIA_QUERY,
"""
${AttachmentTable.DATA_FILE} IS NOT NULL AND
(${AttachmentTable.DATA_FILE} IS NOT NULL OR ${AttachmentTable.TRANSFER_STATE} = ${AttachmentTable.TRANSFER_RESTORE_OFFLOADED}) AND
(
${AttachmentTable.CONTENT_TYPE} LIKE 'image/svg%' OR
${AttachmentTable.CONTENT_TYPE} LIKE 'image/svg%' OR
(
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/%' AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'video/%' AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'audio/%' AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/%' AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'video/%' AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'audio/%' AND
${AttachmentTable.CONTENT_TYPE} NOT LIKE 'text/x-signal-plain' AND
${MessageTable.SCHEDULED_DATE} < 0
)
@@ -229,9 +232,6 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
}
private fun applyIndexHint(query: String, threadId: Long, sorting: Sorting): String {
if (threadId == ALL_THREADS.toLong() && sorting == Sorting.Largest) {
return query.replace("__INDEX_HINT__", "INDEXED BY attachment_media_overview_size")
}
return query.replace("__INDEX_HINT__", "")
}
}
@@ -285,8 +285,17 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
@JvmOverloads
fun getAllMediaForThread(threadId: Long, sorting: Sorting, limit: Int = 0): Cursor {
var query = sorting.applyToQuery(applyEqualityOperator(threadId, applyIndexHint(ALL_MEDIA_QUERY, threadId, sorting)))
val args = arrayOf(threadId.toString() + "")
val allMediaSubquery = applyEqualityOperator(threadId, applyIndexHint(ALL_MEDIA_QUERY, threadId, sorting))
val linkSubquery = applyEqualityOperator(threadId, LINK_MEDIA_QUERY)
val orderBy = when (sorting) {
Sorting.Newest -> " ORDER BY $MEDIA_MESSAGE_ID DESC"
Sorting.Oldest -> " ORDER BY $MEDIA_MESSAGE_ID ASC"
Sorting.Largest -> " ORDER BY ${AttachmentTable.DATA_SIZE} DESC"
}
var query = "$allMediaSubquery UNION ALL $linkSubquery$orderBy"
val args = arrayOf(threadId.toString(), threadId.toString())
if (limit > 0) {
query = "$query LIMIT $limit"
@@ -344,6 +353,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
val recipientId: RecipientId,
val threadRecipientId: RecipientId,
val threadId: Long,
val messageId: Long,
val date: Long,
val isOutgoing: Boolean,
val linkPreviewJson: String? = null
@@ -363,6 +373,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
recipientId = RecipientId.from(cursor.requireLong(MessageTable.FROM_RECIPIENT_ID)),
threadId = cursor.requireLong(MessageTable.THREAD_ID),
threadRecipientId = RecipientId.from(cursor.requireLong(THREAD_RECIPIENT_ID)),
messageId = cursor.requireLong(MEDIA_MESSAGE_ID),
date = if (MessageTypes.isPushType(cursor.requireLong(MessageTable.TYPE))) {
cursor.requireLong(MessageTable.DATE_SENT)
} else {
@@ -3062,7 +3062,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (retrieved.attachments.isEmpty() && editedMessage?.id != null && attachments.getAttachmentsForMessage(editedMessage.id).isNotEmpty()) {
val linkPreviewAttachmentIds = editedMessage.linkPreviews.mapNotNull { it.attachmentId?.id }.toSet()
attachments.duplicateAttachmentsForMessage(messageId, editedMessage.id, linkPreviewAttachmentIds)
val textAttachmentIds = editedMessage.slideDeck.asAttachments().filter { it.contentType == MediaUtil.LONG_TEXT }.mapNotNull { (it as? DatabaseAttachment)?.attachmentId?.id }.toSet()
val excludeIds = linkPreviewAttachmentIds + textAttachmentIds
attachments.duplicateAttachmentsForMessage(messageId, editedMessage.id, excludeIds)
}
val isNotStoryGroupReply = retrieved.parentStoryId == null || !retrieved.parentStoryId.isGroupReply()
@@ -3629,12 +3631,29 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.limit(1)
.run()
.readToSingleObject { cursor ->
PotentialCollapsibleMessage(
val message = PotentialCollapsibleMessage(
type = cursor.requireLong(TYPE),
dateReceived = cursor.requireLong(DATE_RECEIVED),
collapsedHeadId = cursor.requireLong(COLLAPSED_HEAD_ID),
messageExtras = cursor.requireBlob(MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
)
val collapsedSize = if (message.collapsedHeadId != 0L) {
readableDatabase
.count()
.from(TABLE_NAME)
.where("$COLLAPSED_HEAD_ID = ?", message.collapsedHeadId)
.run()
.readToSingleInt()
} else {
-1
}
if (collapsedSize in 1..<CollapsibleEvents.MAX_SIZE) {
message
} else {
null
}
}?.takeIf { DateUtils.isSameDay(it.dateReceived, dateReceived) }
}
@@ -6212,7 +6231,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
/**
* Returns the number of updates that belong in a collapsed update set where [messageId] is the head (first update) in that set
* Returns the number of updates that belong in a collapsed update set where [messageId] is the head (first update) in that set.
* If an event is [PENDING_COLLAPSED], we do not want to consider it part of the count until it is seen.
*/
fun getCollapsedCount(messageId: Long): Int {
@@ -2322,7 +2322,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
.values(NEEDS_PNI_SIGNATURE to 0)
.run()
clearSelfKeyTransparencyData()
SignalDatabase.pendingPniSignatureMessages.deleteAll()
db.setTransactionSuccessful()
@@ -2351,10 +2350,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
if (id == Recipient.self().id) {
clearSelfKeyTransparencyData()
}
if (update(id, contentValuesOf(USERNAME to username))) {
AppDependencies.databaseObserver.notifyRecipientChanged(id)
rotateStorageId(id)
@@ -910,6 +910,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
where += " AND $ARCHIVED = 0"
where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = 0"
where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = 0"
if (SignalStore.releaseChannel.releaseChannelRecipientId != null) {
where += " AND $TABLE_NAME.$RECIPIENT_ID != ${SignalStore.releaseChannel.releaseChannelRecipientId!!.toLong()}"
@@ -185,6 +185,7 @@ public final class GroupedThreadMediaLoader extends AsyncTaskLoader<GroupedThrea
@Override
public int groupForRecord(@NonNull MediaTable.MediaRecord mediaRecord) {
if (mediaRecord.getAttachment() == null) return SMALL;
long size = mediaRecord.getAttachment().size;
if (size < MB) return SMALL;
@@ -118,6 +118,11 @@ object GroupsV2UpdateMessageConverter {
}
}
if (group != null && group.terminated) {
updates.add(GroupChangeChatUpdate.Update(groupTerminateChangeUpdate = GroupTerminateChangeUpdate(updaterAci = null)))
return GroupChangeChatUpdate(updates = updates)
}
if (group != null && DecryptedGroupUtil.findMemberByAci(group.members, selfIds.aci).isPresent) {
updates.add(GroupChangeChatUpdate.Update(groupMemberJoinedUpdate = GroupMemberJoinedUpdate(newMemberAci = selfIds.aci.toByteString())))
}
@@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.dependencies
import org.signal.libsignal.keytrans.KeyTransparencyException
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.KeyTransparency.CheckMode
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.getOrError
import org.signal.libsignal.protocol.IdentityKey
@@ -14,21 +14,9 @@ import org.whispersystems.signalservice.api.websocket.SignalWebSocket
*/
class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
/**
* Uses KT to verify recipient. This is an unauthenticated and should only be called the first time KT is being requested for this recipient.
*/
suspend fun search(aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
suspend fun check(checkMode: CheckMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
}.getOrError()
}
/**
* Monitors KT to verify recipient. This is an unauthenticated and should only be called following a successful [search].
*/
suspend fun monitor(monitorMode: KeyTransparency.MonitorMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
return unauthWebSocket.runCatchingWithUnauthChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().monitor(monitorMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
chatConnection.keyTransparencyClient().check(checkMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
}.getOrError()
}
}
@@ -8,6 +8,7 @@ import android.os.Bundle;
import android.provider.Settings;
import android.text.method.LinkMovementMethod;
import android.view.View;
import android.view.WindowManager;
import android.widget.TextView;
import android.widget.Toast;
@@ -70,6 +71,8 @@ public abstract class DeviceTransferSetupFragment extends LoggingFragment {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
Group progressGroup = view.findViewById(R.id.device_transfer_setup_fragment_progress_group);
Group errorGroup = view.findViewById(R.id.device_transfer_setup_fragment_error_group);
View verifyGroup = view.findViewById(R.id.device_transfer_setup_fragment_verify);
@@ -274,6 +277,7 @@ public abstract class DeviceTransferSetupFragment extends LoggingFragment {
@Override
public void onDestroyView() {
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
cancelTakingTooLong();
EventBus.getDefault().unregister(this);
super.onDestroyView();
@@ -67,9 +67,9 @@ class PushProcessMessageJobMigration : JobMigration(10) {
val envelope = Envelope.Builder()
.sourceServiceId(sourceServiceId.toString())
.sourceDevice(proto.metadata!!.senderDevice)
.sourceDeviceId(proto.metadata!!.senderDevice)
.destinationServiceId(destinationServiceId.toString())
.timestamp(proto.metadata!!.timestamp)
.clientTimestamp(proto.metadata!!.timestamp)
.serverGuid(proto.metadata!!.serverGuid)
.serverTimestamp(proto.metadata!!.serverReceivedTimestamp)
@@ -108,6 +108,11 @@ class AdminDeleteSendJob private constructor(
}
val groupRecord = SignalDatabase.groups.getGroup(conversationRecipient.requireGroupId())
if (groupRecord.isPresent && groupRecord.get().isTerminated) {
Log.w(TAG, "Cannot admin delete in a terminated group.")
return Result.failure()
}
if (groupRecord.isEmpty || !groupRecord.get().isAdmin(Recipient.self())) {
Log.w(TAG, "Cannot delete because you are not an admin.")
return Result.failure()
@@ -8,12 +8,12 @@ package org.thoughtcrime.securesms.jobs
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.glide.decryptableuri.DecryptableUri
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.ArchiveDatabaseExecutor
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.UploadedThumbnailInfo
import org.thoughtcrime.securesms.backup.v2.hadIntegrityCheckPerformed
import org.thoughtcrime.securesms.backup.v2.requireThumbnailMediaName
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -30,12 +30,12 @@ import org.thoughtcrime.securesms.util.ImageCompressionUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import java.io.ByteArrayInputStream
import java.io.IOException
import java.util.Optional
import kotlin.math.floor
import kotlin.math.max
import kotlin.time.Duration.Companion.days
@@ -176,49 +176,24 @@ class ArchiveThumbnailUploadJob private constructor(
return Result.failure()
}
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
val specResult = BackupRepository
.getAttachmentUploadForm()
.then { form ->
SignalNetwork.attachments.getResumableUploadSpec(
key = mediaRootBackupKey.deriveThumbnailTransitKey(attachment.requireThumbnailMediaName()),
iv = Util.getSecretBytes(16),
uploadForm = form
)
}
if (isCanceled) {
ArchiveDatabaseExecutor.runBlocking {
SignalDatabase.attachments.setArchiveThumbnailTransferState(attachmentId, AttachmentTable.ArchiveTransferState.TEMPORARY_FAILURE)
}
return Result.failure()
}
val resumableUpload = when (specResult) {
is NetworkResult.Success -> {
Log.d(TAG, "Got an upload spec!")
specResult.result.toProto()
}
val form: AttachmentUploadForm = when (val formResult = BackupRepository.getAttachmentUploadForm()) {
is NetworkResult.Success -> formResult.result
is NetworkResult.ApplicationError -> {
Log.w(TAG, "Failed to get an upload spec due to an application error. Retrying.", specResult.throwable)
Log.w(TAG, "Failed to get upload form due to an application error. Retrying.", formResult.throwable)
return Result.retry(defaultBackoff())
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "Encountered a transient network error when getting upload spec. Retrying.")
Log.w(TAG, "Encountered a transient network error when getting upload form. Retrying.")
return Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
return when (specResult.code) {
return when (formResult.code) {
429 -> {
Log.w(TAG, "Rate limited when getting upload spec.")
Result.retry(specResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
Log.w(TAG, "Rate limited when getting upload form.")
Result.retry(formResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.w(TAG, "Failed to get an upload spec with status code ${specResult.code}")
Log.w(TAG, "Failed to get upload form with status code ${formResult.code}")
Result.retry(defaultBackoff())
}
}
@@ -232,13 +207,31 @@ class ArchiveThumbnailUploadJob private constructor(
return Result.failure()
}
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
val key = mediaRootBackupKey.deriveThumbnailTransitKey(attachment.requireThumbnailMediaName())
val iv = Util.getSecretBytes(16)
val checksumSha256 = ByteArrayInputStream(thumbnailResult.data).use { stream ->
AttachmentUploadUtil.computeCiphertextChecksum(key, iv, stream, thumbnailResult.data.size.toLong())
}
val attachmentPointer = try {
buildSignalServiceAttachmentStream(thumbnailResult, resumableUpload).use { stream ->
val pointer = AppDependencies.signalServiceMessageSender.uploadAttachment(stream)
PointerAttachment.forPointer(Optional.of(pointer)).get()
val uploadResult: AttachmentUploadResult = buildSignalServiceAttachmentStream(thumbnailResult).use { stream ->
when (val result = SignalNetwork.attachments.uploadAttachmentV4(form, key, iv, checksumSha256, stream)) {
is NetworkResult.Success -> result.result
is NetworkResult.ApplicationError -> throw result.throwable
is NetworkResult.NetworkError -> throw result.exception
is NetworkResult.StatusCodeError -> throw IOException("Upload failed with status ${result.code}")
}
}
UploadedThumbnailInfo(
cdnNumber = uploadResult.cdnNumber,
remoteLocation = uploadResult.remoteId.toString(),
size = uploadResult.dataSize
)
} catch (e: IOException) {
Log.w(TAG, "Failed to upload attachment", e)
Log.w(TAG, "Failed to upload thumbnail", e)
return Result.retry(defaultBackoff())
}
@@ -336,7 +329,7 @@ class ArchiveThumbnailUploadJob private constructor(
return result
}
private fun buildSignalServiceAttachmentStream(result: ImageCompressionUtil.Result, uploadSpec: ResumableUpload): SignalServiceAttachmentStream {
private fun buildSignalServiceAttachmentStream(result: ImageCompressionUtil.Result): SignalServiceAttachmentStream {
return SignalServiceAttachment.newStreamBuilder()
.withStream(ByteArrayInputStream(result.data))
.withContentType(result.mimeType)
@@ -344,7 +337,6 @@ class ArchiveThumbnailUploadJob private constructor(
.withWidth(result.width)
.withHeight(result.height)
.withUploadTimestamp(System.currentTimeMillis())
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
.build()
}
@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.ImageCompressionUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MemoryFileDescriptor.MemoryFileException;
import org.signal.core.util.MemoryFileDescriptor.MemoryFileException;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.video.StreamingTranscoder;
import org.thoughtcrime.securesms.video.TranscoderOptions;
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec
import org.thoughtcrime.securesms.jobs.protos.AttachmentUploadJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.net.NotPushRegisteredException
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
@@ -44,6 +45,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStre
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResumableUploadResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
@@ -146,7 +148,7 @@ class AttachmentUploadJob private constructor(
val timeSinceUpload = System.currentTimeMillis() - databaseAttachment.uploadTimestamp
if (timeSinceUpload < UPLOAD_REUSE_THRESHOLD && !TextUtils.isEmpty(databaseAttachment.remoteLocation)) {
Log.i(TAG, "We can re-use an already-uploaded file. It was uploaded $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days) ago. Skipping.")
Log.i(TAG, "[$attachmentId] We can re-use an already-uploaded file. It was uploaded $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days) ago. Skipping.")
SignalDatabase.attachments.setTransferState(databaseAttachment.mmsId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
if (SignalStore.account.isPrimaryDevice && BackupRepository.shouldCopyAttachmentToArchive(databaseAttachment.attachmentId, databaseAttachment.mmsId)) {
Log.i(TAG, "[$attachmentId] The re-used file was not copied to the archive. Copying now.")
@@ -154,39 +156,50 @@ class AttachmentUploadJob private constructor(
}
return
} else if (databaseAttachment.uploadTimestamp > 0) {
Log.i(TAG, "This file was previously-uploaded, but too long ago to be re-used. Age: $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days)")
Log.i(TAG, "[$attachmentId] This file was previously-uploaded, but too long ago to be re-used. Age: $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days)")
if (databaseAttachment.archiveTransferState != AttachmentTable.ArchiveTransferState.NONE) {
SignalDatabase.attachments.clearArchiveData(attachmentId)
}
}
if (uploadSpec != null && System.currentTimeMillis() > uploadSpec!!.timeout) {
Log.w(TAG, "Upload spec expired! Clearing.")
Log.w(TAG, "[$attachmentId] Upload spec expired! Clearing.")
uploadSpec = null
}
if (uploadSpec == null) {
Log.d(TAG, "Need an upload spec. Fetching...")
uploadSpec = SignalNetwork.attachments
.getAttachmentV4UploadForm()
.then { form ->
SignalNetwork.attachments.getResumableUploadSpec(
key = Base64.decode(databaseAttachment.remoteKey!!),
iv = Util.getSecretBytes(16),
uploadForm = form
)
}
.successOrThrow()
.toProto()
} else {
Log.d(TAG, "Re-using existing upload spec.")
}
Log.i(TAG, "Uploading attachment for message " + databaseAttachment.mmsId + " with ID " + databaseAttachment.attachmentId)
Log.i(TAG, "[$attachmentId] Uploading attachment for message ${databaseAttachment.mmsId}")
try {
val existingSpec = uploadSpec?.let { ResumableUploadSpec.from(it) }
val uploadForm = if (existingSpec == null) {
SignalNetwork.attachments.getAttachmentV4UploadForm().successOrThrow()
} else {
null
}
val key = existingSpec?.attachmentKey ?: Base64.decode(databaseAttachment.remoteKey!!)
val iv = existingSpec?.attachmentIv ?: Util.getSecretBytes(16)
val checksumSha256 = if (existingSpec == null) {
PartAuthority.getAttachmentStream(context, databaseAttachment.uri!!).use { stream ->
AttachmentUploadUtil.computeCiphertextChecksum(key, iv, stream, databaseAttachment.size)
}
} else {
null
}
getAttachmentNotificationIfNeeded(databaseAttachment).use { notification ->
buildAttachmentStream(databaseAttachment, notification, uploadSpec!!).use { localAttachment ->
val uploadResult: AttachmentUploadResult = SignalNetwork.attachments.uploadAttachmentV4(localAttachment).successOrThrow()
buildAttachmentStream(databaseAttachment, notification).use { localAttachment ->
val uploadResult: AttachmentUploadResult = SignalNetwork.attachments.uploadAttachmentV4(
form = uploadForm,
key = key,
iv = iv,
checksumSha256 = checksumSha256,
attachmentStream = localAttachment,
existingSpec = existingSpec,
onSpecCreated = { spec -> uploadSpec = spec.toProto() }
).successOrThrow()
SignalDatabase.attachments.finalizeAttachmentAfterUpload(databaseAttachment.attachmentId, uploadResult)
if (SignalStore.backup.backsUpMedia) {
val messageId = SignalDatabase.attachments.getMessageId(databaseAttachment.attachmentId)
@@ -235,7 +248,7 @@ class AttachmentUploadJob private constructor(
throw e
} catch (e: NonSuccessfulResumableUploadResponseCodeException) {
if (e.code == 400) {
Log.w(TAG, "Failed to upload due to a 400 when getting resumable upload information. Clearing upload spec.", e)
Log.w(TAG, "[$attachmentId] Failed to upload due to a 400 when getting resumable upload information. Clearing upload spec.", e)
uploadSpec = null
}
@@ -243,7 +256,7 @@ class AttachmentUploadJob private constructor(
throw e
} catch (e: ResumeLocationInvalidException) {
Log.w(TAG, "Resume location invalid. Clearing upload spec.", e)
Log.w(TAG, "[$attachmentId] Resume location invalid. Clearing upload spec.", e)
uploadSpec = null
resetProgressListeners(databaseAttachment)
@@ -268,7 +281,7 @@ class AttachmentUploadJob private constructor(
val database = SignalDatabase.attachments
val databaseAttachment = database.getAttachment(attachmentId)
if (databaseAttachment == null) {
Log.i(TAG, "Could not find attachment in DB for upload job upon failure/cancellation.")
Log.i(TAG, "[$attachmentId] Could not find attachment in DB for upload job upon failure/cancellation.")
return
}
@@ -280,7 +293,7 @@ class AttachmentUploadJob private constructor(
}
@Throws(InvalidAttachmentException::class)
private fun buildAttachmentStream(attachment: Attachment, notification: AttachmentProgressService.Controller?, resumableUploadSpec: ResumableUpload): SignalServiceAttachmentStream {
private fun buildAttachmentStream(attachment: Attachment, notification: AttachmentProgressService.Controller?): SignalServiceAttachmentStream {
if (attachment.uri == null || attachment.size == 0L) {
throw InvalidAttachmentException(IOException("Outgoing attachment has no data!"))
}
@@ -289,7 +302,6 @@ class AttachmentUploadJob private constructor(
AttachmentUploadUtil.buildSignalServiceAttachmentStream(
context = context,
attachment = attachment,
uploadSpec = resumableUploadSpec,
cancellationSignal = { isCanceled },
progressListener = object : SignalServiceAttachment.ProgressListener {
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireLong
@@ -16,6 +15,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.BackfillCollapsedMessageJob.Companion.BATCH_SIZE
import org.thoughtcrime.securesms.jobs.protos.BackfillCollapsedMessageJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.DateUtils
/**
@@ -51,61 +51,70 @@ class BackfillCollapsedMessageJob private constructor(
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (SignalStore.misc.completedCollapsedEventsMigration) {
Log.i(TAG, "Already completed migration")
return Result.success()
}
val db = SignalDatabase.rawDatabase
val messages = db
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.COLLAPSED_STATE, MessageTable.MESSAGE_EXTRAS)
var messageCount = 0
var lastProcessedDateReceived = lastDateReceived
// Tracks the last/previous message to compare against the current message when determining collapsed state
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
db
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.MESSAGE_EXTRAS)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.DATE_RECEIVED} > ?", lastDateReceived)
.orderBy("${MessageTable.DATE_RECEIVED}, ${MessageTable.ID}")
.limit(BATCH_SIZE)
.run()
.readToList { cursor ->
PotentialCollapsibleMessage(
id = cursor.requireLong(MessageTable.ID),
threadId = cursor.requireLong(MessageTable.THREAD_ID),
type = cursor.requireLong(MessageTable.TYPE),
dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED),
collapsedState = cursor.requireLong(MessageTable.COLLAPSED_STATE),
read = cursor.requireBoolean(MessageTable.READ),
messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
)
}
.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.requireLong(MessageTable.ID)
val threadId = cursor.requireLong(MessageTable.THREAD_ID)
val type = cursor.requireLong(MessageTable.TYPE)
val dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED)
val read = cursor.requireBoolean(MessageTable.READ)
val messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
// Tracks the last/previous message to compare against the current message when determining collapsed state
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
for (message in messages) {
val collapsibleType = CollapsibleEvents.getCollapsibleType(message.type, message.messageExtras)
val collapsibleType = CollapsibleEvents.getCollapsibleType(type, messageExtras)
if (collapsibleType == null) {
lastMessageByThread[message.threadId] = null
} else {
val previous = lastMessageByThread[message.threadId]
if (collapsibleType == null) {
lastMessageByThread[threadId] = null
} else {
val previous = lastMessageByThread[threadId]
val (collapsedState, headId) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, message.dateReceived)) {
val state = if (message.read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
Pair(state, previous.headId)
} else {
Pair(CollapsedState.HEAD_COLLAPSED.id, message.id)
val (collapsedState, headId, size) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, dateReceived) && previous.collapsedSetSize < CollapsibleEvents.MAX_SIZE) {
val state = if (read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
Triple(state, previous.headId, previous.collapsedSetSize)
} else {
Triple(CollapsedState.HEAD_COLLAPSED.id, id, 0)
}
db.update(MessageTable.TABLE_NAME)
.values(
MessageTable.COLLAPSED_STATE to collapsedState,
MessageTable.COLLAPSED_HEAD_ID to headId
)
.where("${MessageTable.ID} = ?", id)
.run()
lastMessageByThread[threadId] = LastMessage(collapsibleType, headId, dateReceived, size + 1)
}
messageCount++
lastProcessedDateReceived = dateReceived
}
db.update(MessageTable.TABLE_NAME)
.values(
MessageTable.COLLAPSED_STATE to collapsedState,
MessageTable.COLLAPSED_HEAD_ID to headId
)
.where("${MessageTable.ID} = ?", message.id)
.run()
lastMessageByThread[message.threadId] = LastMessage(collapsibleType, headId, message.dateReceived)
}
}
if (messages.isEmpty() || messages.size != BATCH_SIZE) {
if (messageCount == 0 || messageCount != BATCH_SIZE) {
Log.i(TAG, "Finished processing all messages, backfill is completed")
SignalStore.misc.completedCollapsedEventsMigration = true
} else {
val dateReceived = messages.last().dateReceived
Log.i(TAG, "Processed ${messages.size} messages, up to time $dateReceived. Re-enqueuing job")
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = dateReceived))
Log.i(TAG, "Processed $messageCount messages, up to time $lastProcessedDateReceived. Re-enqueuing job")
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = lastProcessedDateReceived))
}
return Result.success()
@@ -115,26 +124,14 @@ class BackfillCollapsedMessageJob private constructor(
Log.w(TAG, "Failed to backfill collapsed messages. Time of last processed message: $lastDateReceived")
}
/**
* Data required from a message to know if it collapsible
*/
private data class PotentialCollapsibleMessage(
val id: Long,
val threadId: Long,
val type: Long,
val dateReceived: Long,
val collapsedState: Long,
val read: Boolean,
val messageExtras: MessageExtras?
)
/**
* Information about the previous message, used when deciding the collapsible state of the next
*/
private data class LastMessage(
val collapsibleType: CollapsibleEvents.CollapsibleType?,
val headId: Long,
val dateReceived: Long
val dateReceived: Long,
val collapsedSetSize: Int
)
class Factory : Job.Factory<BackfillCollapsedMessageJob> {
@@ -25,6 +25,7 @@ import org.signal.libsignal.net.SvrBStoreResponse
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
@@ -108,15 +109,7 @@ class BackupMessagesJob private constructor(
return
}
val jobManager = AppDependencies.jobManager
val chain = jobManager.startChain(BackupMessagesJob())
if (SignalStore.backup.optimizeStorage && SignalStore.backup.backsUpMedia) {
chain.then(OptimizeMediaJob())
}
chain.enqueue()
AppDependencies.jobManager.add(BackupMessagesJob())
}
fun cancel() {
@@ -158,6 +151,14 @@ class BackupMessagesJob private constructor(
}
override fun run(): Result {
val result = doWork()
if (result.isSuccess && !isCanceled && SignalStore.backup.optimizeStorage && SignalStore.backup.backsUpMedia) {
AppDependencies.jobManager.add(OptimizeMediaJob())
}
return result
}
private fun doWork(): Result {
if (!isBackupAllowed()) {
Log.d(TAG, "Skip running BackupMessagesJob.", true)
return Result.success()
@@ -294,49 +295,45 @@ class BackupMessagesJob private constructor(
this.syncTime = currentTime
this.dataFile = tempBackupFile.path
val backupSpec: ResumableMessagesBackupUploadSpec = resumableMessagesBackupUploadSpec ?: when (val result = BackupRepository.getResumableMessagesBackupUploadSpec(tempBackupFile.length())) {
is NetworkResult.Success -> {
Log.i(TAG, "Successfully generated a new upload spec.", true)
val existingSpec = resumableMessagesBackupUploadSpec
val form: AttachmentUploadForm = if (existingSpec == null) {
when (val result = BackupRepository.getMessageBackupUploadForm(tempBackupFile.length())) {
is NetworkResult.Success -> result.result
is NetworkResult.NetworkError -> {
Log.i(TAG, "Network failure", result.getCause(), true)
return Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
413 -> {
Log.i(TAG, "Backup file is too large! Size: ${tempBackupFile.length()} bytes. Current threshold: ${SignalStore.backup.messageCuttoffDuration}", result.getCause(), true)
tempBackupFile.delete()
this.dataFile = ""
BackupRepository.markBackupCreationFailed(BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE)
backupErrorHandled = true
val spec = result.result
resumableMessagesBackupUploadSpec = spec
spec
}
is NetworkResult.NetworkError -> {
Log.i(TAG, "Network failure", result.getCause(), true)
return Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
413 -> {
Log.i(TAG, "Backup file is too large! Size: ${tempBackupFile.length()} bytes. Current threshold: ${SignalStore.backup.messageCuttoffDuration}", result.getCause(), true)
tempBackupFile.delete()
this.dataFile = ""
BackupRepository.markBackupCreationFailed(BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE)
backupErrorHandled = true
if (SignalStore.backup.messageCuttoffDuration == null) {
Log.i(TAG, "Setting message cuttoff duration to $TOO_LARGE_MESSAGE_CUTTOFF_DURATION", true)
SignalStore.backup.messageCuttoffDuration = TOO_LARGE_MESSAGE_CUTTOFF_DURATION
if (SignalStore.backup.messageCuttoffDuration == null) {
Log.i(TAG, "Setting message cuttoff duration to $TOO_LARGE_MESSAGE_CUTTOFF_DURATION", true)
SignalStore.backup.messageCuttoffDuration = TOO_LARGE_MESSAGE_CUTTOFF_DURATION
return Result.retry(defaultBackoff())
} else {
return Result.failure()
}
}
429 -> {
Log.i(TAG, "Rate limited when getting upload form.", result.getCause(), true)
return Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.i(TAG, "Status code failure", result.getCause(), true)
return Result.retry(defaultBackoff())
} else {
return Result.failure()
}
}
429 -> {
Log.i(TAG, "Rate limited when getting upload spec.", result.getCause(), true)
return Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.i(TAG, "Status code failure", result.getCause(), true)
return Result.retry(defaultBackoff())
}
}
is NetworkResult.ApplicationError -> throw result.throwable
}
is NetworkResult.ApplicationError -> throw result.throwable
} else {
existingSpec.attachmentUploadForm
}
val progressListener = object : SignalServiceAttachment.ProgressListener {
@@ -347,56 +344,58 @@ class BackupMessagesJob private constructor(
override fun shouldCancel(): Boolean = isCanceled
}
FileInputStream(tempBackupFile).use { fileStream ->
val uploadResult = SignalNetwork.archive.uploadBackupFile(
uploadForm = backupSpec.attachmentUploadForm,
resumableUploadUrl = backupSpec.resumableUri,
val checksumSha256 = if (existingSpec == null) {
FileInputStream(tempBackupFile).use { AttachmentUploadUtil.computeRawChecksum(it) }
} else {
null
}
val uploadResult = FileInputStream(tempBackupFile).use { fileStream ->
SignalNetwork.archive.uploadBackupFile(
uploadForm = form,
data = fileStream,
dataLength = tempBackupFile.length(),
progressListener = progressListener
checksumSha256 = checksumSha256,
progressListener = progressListener,
existingResumeUrl = existingSpec?.resumableUri,
onResumeUrlCreated = { url ->
resumableMessagesBackupUploadSpec = ResumableMessagesBackupUploadSpec(attachmentUploadForm = form, resumableUri = url)
}
)
when (uploadResult) {
is NetworkResult.Success -> {
Log.i(TAG, "Successfully uploaded backup file.", true)
if (!SignalStore.backup.hasBackupBeenUploaded) {
Log.i(TAG, "First time making a backup - scheduling a storage sync.", true)
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.backup.hasBackupBeenUploaded = true
}
is NetworkResult.NetworkError -> {
Log.i(TAG, "Network failure", uploadResult.getCause(), true)
return if (isCanceled) {
Result.failure()
} else {
Result.retry(defaultBackoff())
}
}
is NetworkResult.StatusCodeError -> {
when (uploadResult.code) {
400 -> {
Log.w(TAG, "400 likely means bad resumable state. Resetting the upload spec before retrying.", true)
resumableMessagesBackupUploadSpec = null
return Result.retry(defaultBackoff())
}
429 -> {
Log.w(TAG, "Rate limited when uploading backup file.", uploadResult.getCause(), true)
return Result.retry(uploadResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.i(TAG, "Status code failure (${uploadResult.code})", uploadResult.getCause(), true)
return Result.retry(defaultBackoff())
}
}
}
is NetworkResult.ApplicationError -> throw uploadResult.throwable
}
}
when (uploadResult) {
is NetworkResult.Success -> Unit
is NetworkResult.NetworkError -> {
Log.i(TAG, "Network failure", uploadResult.getCause(), true)
return if (isCanceled) Result.failure() else Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
when (uploadResult.code) {
400 -> {
Log.w(TAG, "400 likely means bad resumable state. Resetting the upload spec before retrying.", true)
resumableMessagesBackupUploadSpec = null
return Result.retry(defaultBackoff())
}
429 -> {
Log.w(TAG, "Rate limited when uploading backup file.", uploadResult.getCause(), true)
return Result.retry(uploadResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.i(TAG, "Status code failure (${uploadResult.code})", uploadResult.getCause(), true)
return Result.retry(defaultBackoff())
}
}
}
is NetworkResult.ApplicationError -> throw uploadResult.throwable
}
Log.i(TAG, "Successfully uploaded backup file.", true)
if (!SignalStore.backup.hasBackupBeenUploaded) {
Log.i(TAG, "First time making a backup - scheduling a storage sync.", true)
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.backup.hasBackupBeenUploaded = true
stopwatch.split("upload")
SignalStore.backup.nextBackupSecretData = svrBMetadata.nextBackupSecretData
@@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.KeyTransparency.CheckMode
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
@@ -110,25 +110,16 @@ class CheckKeyTransparencyJob private constructor(
SignalStore.misc.lastKeyTransparencyTime = System.currentTimeMillis()
val recipient = SignalDatabase.recipients.getRecord(Recipient.self().id)
val aciIdentityKey = SignalStore.account.aciIdentityKey.publicKey
val aci = recipient.aci!!.libSignalAci
val (e164, unidentifiedAccessKey) = if (SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.DISCOVERABLE) {
Pair(recipient.e164!!, ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) })
} else {
Pair(null, null)
}
val usernameHash = SignalStore.account.username?.let { Username(it).hash }
val firstSearch = recipient.keyTransparencyData == null
val result = if (firstSearch) {
Log.i(TAG, "First search in key transparency")
SignalNetwork.keyTransparency.search(aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, KeyTransparencyStore)
} else {
Log.i(TAG, "Monitoring search in key transparency")
SignalNetwork.keyTransparency.monitor(KeyTransparency.MonitorMode.SELF, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, KeyTransparencyStore)
}
val result = SignalNetwork.keyTransparency.check(
checkMode = CheckMode.Self(isE164Discoverable = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.DISCOVERABLE),
aci = recipient.aci!!.libSignalAci,
aciIdentityKey = SignalStore.account.aciIdentityKey.publicKey,
e164 = recipient.e164!!,
unidentifiedAccessKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) },
usernameHash = SignalStore.account.username?.let { Username(it).hash },
keyTransparencyStore = KeyTransparencyStore
)
Log.i(TAG, "Key transparency complete, result: $result")
return when (result) {
@@ -7,9 +7,10 @@ import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.messages.GroupSendUtil;
import org.thoughtcrime.securesms.net.NotPushRegisteredException;
@@ -121,6 +122,11 @@ public class GroupCallUpdateSendJob extends BaseJob {
throw new AssertionError("We have a recipient, but it's not a V2 Group");
}
if (!SignalDatabase.groups().isActive(conversationRecipient.requireGroupId())) {
Log.w(TAG, "Not sending group call update to terminated or inactive group.");
return;
}
List<Recipient> destinations = Stream.of(recipients).map(Recipient::resolved).toList();
List<Recipient> completions = deliver(conversationRecipient, destinations);
@@ -547,7 +547,20 @@ class InAppPaymentRecurringContextJob private constructor(
}
409 -> {
warning("Already redeemed this token during new subscription. Failing.", applicationError)
warning("Already redeemed this token during new subscription.", applicationError)
if (inAppPayment.type == InAppPaymentType.RECURRING_DONATION) {
info("Token already redeemed for recurring donation. Treating as successful redemption.")
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
state = InAppPaymentTable.State.END,
data = inAppPayment.data.newBuilder().redemption(
redemption = InAppPaymentData.RedemptionState(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED)
).build()
)
)
throw Exception(applicationError)
}
// During keep-alive processing, we don't alert the user about redemption failures.
if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP && inAppPayment.data.redemption?.keepAlive != true) {
@@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.migrations.AttributesMigrationJob;
import org.thoughtcrime.securesms.migrations.AvatarColorStorageServiceMigrationJob;
import org.thoughtcrime.securesms.migrations.AvatarIdRemovalMigrationJob;
import org.thoughtcrime.securesms.migrations.AvatarMigrationJob;
import org.thoughtcrime.securesms.migrations.BackfillCollapsedEventsMigrationJob;
import org.thoughtcrime.securesms.migrations.BackfillDigestsForDuplicatesMigrationJob;
import org.thoughtcrime.securesms.migrations.BackupJitterMigrationJob;
import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob;
@@ -263,6 +264,7 @@ public final class JobManagerFactories {
put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory());
put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory());
put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory());
put(LocalBackupRestoreMediaJob.KEY, new LocalBackupRestoreMediaJob.Factory());
put(RestoreAttachmentJob.KEY, new RestoreAttachmentJob.Factory());
put(RestoreAttachmentThumbnailJob.KEY, new RestoreAttachmentThumbnailJob.Factory());
put(RestoreLocalAttachmentJob.KEY, new RestoreLocalAttachmentJob.Factory());
@@ -312,6 +314,7 @@ public final class JobManagerFactories {
put(AvatarColorStorageServiceMigrationJob.KEY, new AvatarColorStorageServiceMigrationJob.Factory());
put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory());
put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory());
put(BackfillCollapsedEventsMigrationJob.KEY, new BackfillCollapsedEventsMigrationJob.Factory());
put(BackfillDigestsForDuplicatesMigrationJob.KEY, new BackfillDigestsForDuplicatesMigrationJob.Factory());
put(BackupJitterMigrationJob.KEY, new BackupJitterMigrationJob.Factory());
put(BackupNotificationMigrationJob.KEY, new BackupNotificationMigrationJob.Factory());
@@ -1,11 +1,17 @@
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.BackupFileIOError
import org.thoughtcrime.securesms.backup.FullBackupExporter.BackupCanceledException
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
@@ -85,6 +91,13 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
try {
SignalDatabase.attachmentMetadata.insertNewKeysForExistingAttachments()
val progressScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
progressScope.launch {
LocalExportProgress.encryptedProgress.collect { progress ->
updateNotification(progress, notification)
}
}
try {
val result = LocalArchiver.export(snapshotFileSystem, archiveFileSystem.filesFileSystem, stopwatch, cancellationSignal = { isCanceled })
Log.i(TAG, "Archive finished with result: $result")
@@ -94,6 +107,8 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
} catch (e: Exception) {
Log.w(TAG, "Unable to create local archive", e)
return Result.failure()
} finally {
progressScope.cancel()
}
stopwatch.split("archive-create")
@@ -139,11 +154,11 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
}
override fun onFailure() {
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()))
}
private fun setProgress(progress: LocalBackupCreationProgress, notification: NotificationController?) {
SignalStore.backup.newLocalBackupProgress = progress
LocalExportProgress.setEncryptedProgress(progress)
updateNotification(progress, notification)
}
@@ -158,8 +173,25 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
when {
exporting != null -> {
val phase = NotificationPhase.Export(exporting.phase)
if (previousPhase != phase) {
notification.replaceTitle(exporting.phase.toString())
val title = when (exporting.phase) {
LocalBackupCreationProgress.ExportPhase.MESSAGE -> {
if (exporting.frameTotalCount > 0) {
context.getString(
R.string.BackupCreationProgressRow__processing_messages_s_of_s_d,
"%,d".format(exporting.frameExportCount),
"%,d".format(exporting.frameTotalCount),
(exporting.frameExportCount * 100 / exporting.frameTotalCount).toInt()
)
} else {
context.getString(R.string.BackupCreationProgressRow__processing_messages)
}
}
LocalBackupCreationProgress.ExportPhase.FINALIZING -> context.getString(R.string.BackupCreationProgressRow__finalizing)
LocalBackupCreationProgress.ExportPhase.NONE -> context.getString(R.string.BackupCreationProgressRow__processing_backup)
else -> context.getString(R.string.BackupCreationProgressRow__preparing_backup)
}
if (previousPhase != phase || exporting.phase == LocalBackupCreationProgress.ExportPhase.MESSAGE) {
notification.replaceTitle(title)
previousPhase = phase
}
if (exporting.frameTotalCount == 0L) {
@@ -45,6 +45,7 @@ public final class LocalBackupJob extends BaseJob {
private static final String TAG = Log.tag(LocalBackupJob.class);
public static final String QUEUE = "__LOCAL_BACKUP__";
public static final String PLAINTEXT_ARCHIVE_QUEUE = "__LOCAL_PLAINTEXT_ARCHIVE__";
public static final String TEMP_BACKUP_FILE_PREFIX = ".backup";
public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp";
@@ -80,7 +81,7 @@ public final class LocalBackupJob extends BaseJob {
public static void enqueuePlaintextArchive(String destinationUri, boolean includeMedia) {
JobManager jobManager = AppDependencies.getJobManager();
Parameters.Builder parameters = new Parameters.Builder()
.setQueue(QUEUE)
.setQueue(PLAINTEXT_ARCHIVE_QUEUE)
.setMaxInstancesForFactory(1)
.setMaxAttempts(3);
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.LocalBackupRestoreMediaJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.io.File
/**
* Scans the local backup files directory and enqueues individual [RestoreLocalAttachmentJob]s for each restorable attachment.
*/
class LocalBackupRestoreMediaJob private constructor(
parameters: Parameters,
private val backupDirectoryUri: Uri
) : Job(parameters) {
companion object {
const val KEY = "LocalBackupRestoreMediaJob"
private val TAG = Log.tag(LocalBackupRestoreMediaJob::class)
fun create(backupDirectoryUri: Uri): LocalBackupRestoreMediaJob {
return LocalBackupRestoreMediaJob(
Parameters.Builder()
.setLifespan(Parameters.IMMORTAL)
.setMaxAttempts(1)
.build(),
backupDirectoryUri = backupDirectoryUri
)
}
}
override fun serialize(): ByteArray {
return LocalBackupRestoreMediaJobData(
backupDirectoryUri = backupDirectoryUri.toString()
).encode()
}
override fun getFactoryKey(): String = KEY
override fun run(): Result {
val archiveFileSystem = when (backupDirectoryUri.scheme) {
"content" -> ArchiveFileSystem.openForRestore(context, backupDirectoryUri) ?: run {
Log.w(TAG, "Unable to open backup directory: $backupDirectoryUri")
SignalStore.backup.localRestoreDirectoryError = true
return Result.failure()
}
else -> ArchiveFileSystem.fromFile(context, File(backupDirectoryUri.path!!))
}
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
return Result.success()
}
override fun onFailure() {
ArchiveRestoreProgress.allMediaRestored()
// forceUpdate in case restoreState was already NONE and allMediaRestored() skipped its update()
ArchiveRestoreProgress.forceUpdate()
}
class Factory : Job.Factory<LocalBackupRestoreMediaJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): LocalBackupRestoreMediaJob {
val data = LocalBackupRestoreMediaJobData.ADAPTER.decode(serializedData!!)
return LocalBackupRestoreMediaJob(
parameters = parameters,
backupDirectoryUri = Uri.parse(data.backupDirectoryUri)
)
}
}
}
@@ -1,16 +1,25 @@
package org.thoughtcrime.securesms.jobs
import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.signal.core.util.PendingIntentFlags.immutable
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JsonJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.service.GenericForegroundService
@@ -19,7 +28,6 @@ import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipOutputStream
class LocalPlaintextArchiveJob internal constructor(
private val destinationUri: String,
@@ -36,6 +44,8 @@ class LocalPlaintextArchiveJob internal constructor(
private const val KEY_INCLUDE_MEDIA = "include_media"
}
private var exportDir: DocumentFile? = null
override fun serialize(): ByteArray? {
return JsonJobData.Builder()
.putString(KEY_DESTINATION_URI, destinationUri)
@@ -50,13 +60,21 @@ class LocalPlaintextArchiveJob internal constructor(
override fun run(): Result {
Log.i(TAG, "Executing plaintext archive job...")
val contentIntent = PendingIntent.getActivity(
context,
0,
AppSettingsActivity.chats(context).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
immutable()
)
var notification: NotificationController? = null
try {
notification = GenericForegroundService.startForegroundTask(
context,
context.getString(R.string.LocalBackupJob_creating_signal_backup),
NotificationChannels.getInstance().BACKUPS,
R.drawable.ic_signal_backup
R.drawable.ic_signal_backup,
contentIntent
)
} catch (e: UnableToStartException) {
Log.w(TAG, "Unable to start foreground service, continuing without service")
@@ -77,39 +95,46 @@ class LocalPlaintextArchiveJob internal constructor(
val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(Date())
val fileName = "signal-export-$timestamp"
val zipFile = root.createFile("application/zip", fileName)
if (zipFile == null) {
Log.w(TAG, "Unable to create zip file")
exportDir = root.createDirectory(fileName)
val exportDir = this.exportDir ?: run {
Log.w(TAG, "Unable to create export directory")
return Result.failure()
}
stopwatch.split("create-file")
stopwatch.split("create-dir")
try {
SignalDatabase.attachmentMetadata.insertNewKeysForExistingAttachments()
val outputStream = context.contentResolver.openOutputStream(zipFile.uri)
if (outputStream == null) {
Log.w(TAG, "Unable to open output stream for zip file")
zipFile.delete()
return Result.failure()
}
ZipOutputStream(outputStream).use { zipOutputStream ->
val result = LocalArchiver.exportPlaintext(zipOutputStream, includeMedia, stopwatch, cancellationSignal = { isCanceled })
Log.i(TAG, "Plaintext archive finished with result: $result")
if (result !is org.signal.core.util.Result.Success) {
zipFile.delete()
return Result.failure()
val progressScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
progressScope.launch {
LocalExportProgress.plaintextProgress.collect { progress ->
updateNotification(progress, notification)
}
}
try {
val result = LocalArchiver.exportPlaintext(exportDir, context.contentResolver, includeMedia, stopwatch, cancellationSignal = { isCanceled })
Log.i(TAG, "Plaintext archive finished with result: $result")
if (isCanceled) {
exportDir.delete()
setProgress(LocalBackupCreationProgress(canceled = LocalBackupCreationProgress.Canceled()), notification)
return Result.failure()
} else if (result !is org.signal.core.util.Result.Success) {
exportDir.delete()
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
return Result.failure()
}
} finally {
progressScope.cancel()
}
stopwatch.split("archive-create")
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
setProgress(LocalBackupCreationProgress(succeeded = LocalBackupCreationProgress.Succeeded()), notification)
} catch (e: IOException) {
Log.w(TAG, "Error during plaintext archive!", e)
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
zipFile.delete()
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
exportDir.delete()
throw e
}
@@ -122,11 +147,15 @@ class LocalPlaintextArchiveJob internal constructor(
}
override fun onFailure() {
SignalStore.backup.newLocalPlaintextBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
exportDir?.delete()
val current = LocalExportProgress.plaintextProgress.value
if (current.canceled == null && current.failed == null) {
LocalExportProgress.setPlaintextProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()))
}
}
private fun setProgress(progress: LocalBackupCreationProgress, notification: NotificationController?) {
SignalStore.backup.newLocalPlaintextBackupProgress = progress
LocalExportProgress.setPlaintextProgress(progress)
updateNotification(progress, notification)
}
@@ -141,8 +170,25 @@ class LocalPlaintextArchiveJob internal constructor(
when {
exporting != null -> {
val phase = NotificationPhase.Export(exporting.phase)
if (previousPhase != phase) {
notification.replaceTitle(exporting.phase.toString())
val title = when (exporting.phase) {
LocalBackupCreationProgress.ExportPhase.MESSAGE -> {
if (exporting.frameTotalCount > 0) {
context.getString(
R.string.BackupCreationProgressRow__processing_messages_s_of_s_d,
"%,d".format(exporting.frameExportCount),
"%,d".format(exporting.frameTotalCount),
(exporting.frameExportCount * 100 / exporting.frameTotalCount).toInt()
)
} else {
context.getString(R.string.BackupCreationProgressRow__processing_messages)
}
}
LocalBackupCreationProgress.ExportPhase.FINALIZING -> context.getString(R.string.BackupCreationProgressRow__finalizing)
LocalBackupCreationProgress.ExportPhase.NONE -> context.getString(R.string.BackupCreationProgressRow__processing_backup)
else -> context.getString(R.string.BackupCreationProgressRow__preparing_backup)
}
if (previousPhase != phase || exporting.phase == LocalBackupCreationProgress.ExportPhase.MESSAGE) {
notification.replaceTitle(title)
previousPhase = phase
}
if (exporting.frameTotalCount == 0L) {
@@ -56,7 +56,6 @@ class MultiDeviceKeysUpdateJob private constructor(parameters: Parameters) : Bas
val syncMessage = SignalServiceSyncMessage.forKeys(
KeysMessage(
storageService = SignalStore.storageService.storageKey,
master = SignalStore.svr.masterKey,
accountEntropyPool = SignalStore.account.accountEntropyPool,
mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
)
@@ -101,6 +101,11 @@ class PollVoteJob(
return Result.failure()
}
if (conversationRecipient.isPushV2Group && !SignalDatabase.groups.isActive(conversationRecipient.requireGroupId())) {
Log.w(TAG, "Cannot send poll vote to terminated or inactive group.")
return Result.failure()
}
val poll = SignalDatabase.polls.getPoll(messageId)
if (poll == null) {
Log.w(TAG, "Unable to find corresponding poll")
@@ -155,7 +155,7 @@ class PushProcessMessageJob private constructor(
try {
messageProcessor.process(result.envelope, result.content, result.metadata, result.serverDeliveredTimestamp, localMetric = localReceiveMetric, batchCache = batchCache)
} catch (e: Exception) {
Log.e(TAG, "Failed to process message with timestamp ${result.envelope.timestamp}. Dropping.", e)
Log.e(TAG, "Failed to process message with timestamp ${result.envelope.clientTimestamp}. Dropping.", e)
}
null
}
@@ -169,6 +169,11 @@ public class ReactionSendJob extends BaseJob {
return;
}
if (conversationRecipient.isPushV2Group() && !SignalDatabase.groups().isActive(conversationRecipient.requireGroupId())) {
Log.w(TAG, "Cannot send reactions to terminated or inactive groups.");
return;
}
List<Recipient> resolved = recipients.stream().map(Recipient::resolved).collect(Collectors.toList());
List<RecipientId> unregistered = resolved.stream().filter(Recipient::isUnregistered).map(Recipient::getId).collect(Collectors.toList());
List<Recipient> destinations = resolved.stream().filter(Recipient::isMaybeRegistered).collect(Collectors.toList());

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