Compare commits

...

133 Commits

Author SHA1 Message Date
Greyson Parrelli dfd2f7baf9 Bump version to 8.7.2 2026-04-09 22:44:57 -04:00
Greyson Parrelli 5de17a971d Update translations and other static files. 2026-04-09 22:44:34 -04:00
Greyson Parrelli 001896d244 Fix image transition animation. 2026-04-09 22:02:46 -04:00
Michelle Tang 1844b128e1 Use server timestamp for admin delete. 2026-04-09 17:17:55 -04:00
Michelle Tang 08623cc0c4 Use proper sender for early messages. 2026-04-09 15:44:35 -04:00
Greyson Parrelli f93a948169 Fix PIN creation loop during registration. 2026-04-09 13:48:46 -04:00
Cody Henthorne 76476191be Show better error ux for group calls you cannot start. 2026-04-09 10:27:44 -04:00
Greyson Parrelli d00bb28ee4 Bump version to 8.7.1 2026-04-08 22:10:47 -04:00
Greyson Parrelli 453e5bede7 Fix bad bubble tints for chats with wallpapers. 2026-04-08 22:04:17 -04:00
Michelle Tang c7c108bd77 Fix missing gallery photos. 2026-04-08 19:40:13 -04:00
Greyson Parrelli fb81574d35 Bump version to 8.7.0 2026-04-08 16:39:21 -04:00
Greyson Parrelli e6d3de091c Update translations and other static files. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 99b8a6020d Fix flaky registration tests. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 88b21b6113 Improve validator testing. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 256ee9b1aa Delete unused apns database. 2026-04-08 16:39:21 -04:00
Alex Hart e2feaaf74c Add initial working E2E flow for MediaSendV3. 2026-04-08 16:39:21 -04:00
jeffrey-signal 17def87c17 Fix compose preview rendering when using Emojifier. 2026-04-08 16:39:20 -04:00
Alex Hart d90e9919ae Adaptive welcome screen with compact, medium, and large layouts. 2026-04-08 16:39:20 -04:00
Jim Gustafson 38baf17938 Update to RingRTC v2.67.2 2026-04-08 16:39:20 -04:00
scueZ 3f7707985f Skip confusing delete dialog body text in Note to Self.
Resolves #14708
2026-04-08 16:39:20 -04:00
jeffrey-signal a61072b249 Member label performance optimizations. 2026-04-08 16:39:20 -04:00
jeffrey-signal 80ff64ddd3 Prevent unregistered accounts from showing in group call participants. 2026-04-08 16:39:20 -04:00
Cody Henthorne 95c0467bda Show unanswered outgoing calls as unanswered. 2026-04-08 16:39:20 -04:00
Greyson Parrelli ff88d259fd Use long for key id. 2026-04-08 16:39:20 -04:00
Alex Hart 6e747019d4 Fix NPE in toPendingOneTimeDonation when waitForAuth is null.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 16:29:15 -04:00
Greyson Parrelli 9e7a40a63d Extend proper base activity. 2026-04-08 16:29:14 -04:00
Greyson Parrelli 38eed43046 Add long-press context menu in all media screen. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 4c76cb682e Give a media/no-media choice in labs plaintext export. 2026-04-08 15:50:43 -04:00
Michelle Tang c47adb7482 Update padding sizes of update items. 2026-04-08 15:50:43 -04:00
Alex Hart 3c2ccef9a8 Fix upgrade card text color not adapting to dark mode.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 15:50:43 -04:00
jeffrey-signal fb0c4757f2 Fix media count indicator button colors so they match the chat color. 2026-04-08 15:50:43 -04:00
jeffrey-signal b8b9a632b5 Always prefetch wallpaper before opening a conversation. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 9b4a13a491 Potential fix to configuration cache issues with translations. 2026-04-08 15:50:43 -04:00
Cody Henthorne 1cdd49721d Add logging around rotate storage id failures during storage sync. 2026-04-08 15:50:43 -04:00
Cody Henthorne 8b895738c0 Update telecom to 1.1.0-alpha04 2026-04-07 10:05:42 -04:00
Cody Henthorne 6ab3cd3390 Don't show terminated groups after storage service restore. 2026-04-07 09:39:06 -04:00
Alex Hart 11c8a726ec Increment localPlaintextExport flag to lock version. 2026-04-06 16:47:01 -04:00
Alex Hart 264447a6d9 Add breakpoint helper and expand device previews.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang a7bb2831f8 Fix possible misuse of mp4 sanitizer. 2026-04-06 16:47:01 -04:00
Greyson Parrelli e05586a1c9 Convert RegistrationNetworkResult to RequestResult. 2026-04-06 16:47:01 -04:00
Greyson Parrelli 0e8dedf4d0 App ability to regV5 in the main app, behind compile flag. 2026-04-06 16:47:01 -04:00
Michelle Tang 0e11a1fe3e Add logs for voice note proximity. 2026-04-06 16:47:01 -04:00
adel-signal f1ebd2dc81 Add CallingAssetsDownloadJob to app startup to init calling assets
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang 8ea90c8a43 Cancel checking for messages on foreground. 2026-04-06 16:46:05 -04:00
Michelle Tang 6456dcf657 Fix potential edit message race condition. 2026-04-06 16:46:05 -04:00
Greyson Parrelli bb151c91e9 Add basic infra for regV5 local restore. 2026-04-06 16:46:05 -04:00
Greyson Parrelli ce6f39ae68 Update to the new attachment upload form libsignal method. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 58e8ea08c2 Bump to libsignal v0.91.0 2026-04-06 16:46:05 -04:00
Michelle Tang 4dd74d9ab4 Fix collapsed tests. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 3ef3a516b3 Prevent repeated storage-full notifications during backup.
When remote backup storage is full, hundreds of CopyAttachmentToArchiveJob
instances each independently call markOutOfRemoteStorageSpaceError(), which
re-posts the notification every time. Even though the notification ID is the
same, each call re-alerts the user with sound and vibration.

Guard markOutOfRemoteStorageSpaceError() to only post the notification once
by checking the flag before proceeding, and move the flag-set before the
notification post to prevent races. Also add an early exit in
CopyAttachmentToArchiveJob to skip the network quota check when already
marked as out of storage space.
2026-04-06 16:46:05 -04:00
Greyson Parrelli 518a81c7fa Do not start a call while one is in progress. 2026-04-06 16:46:05 -04:00
Michelle Tang f81325e7ca Pause voice notes when joining calls. 2026-04-06 16:46:05 -04:00
Greyson Parrelli cc847cb229 Fix potential glide lifecycle issue with transition animation. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7320a0ef46 Guard against potential crash when reacting to a message. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7c45686440 Fix potential missing ACI crash in verify screen. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 8b5b83e974 Remove unnecessary transaction in LocalMetricsDatabase.
There was a native crash associated with it, unclear the cause, but
maybe this will help.
2026-04-06 16:46:05 -04:00
Michelle Tang a4a3861398 Disable proximity sensor when using headsets for voice notes. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 01bdaaea84 Improve ANR stack trace perf. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 1f02fba696 Include captcha info in support email template. 2026-04-06 16:46:04 -04:00
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
469 changed files with 35287 additions and 22491 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 = 1672
val canonicalVersionName = "8.5.1"
val canonicalVersionCode = 1678
val canonicalVersionName = "8.7.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
}
}
}
@@ -75,8 +75,8 @@ object MockProvider {
val device = PreKeyResponseItem().apply {
this.deviceId = deviceId
registrationId = KeyHelper.generateRegistrationId(false)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
}
return PreKeyResponse().apply {
@@ -212,7 +212,7 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
verb = "PUT",
path = "/api/v1/message",
id = Random.nextLong(),
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
headers = listOf("X-Signal-Timestamp: ${this.serverTimestamp}"),
body = this.encodeByteString()
)
}
@@ -70,8 +70,8 @@ object Generator {
val serverGuid = UUID.randomUUID()
return Envelope.Builder()
.type(Envelope.Type.fromValue(this.type))
.sourceDevice(1)
.timestamp(timestamp)
.sourceDeviceId(1)
.clientTimestamp(timestamp)
.serverTimestamp(timestamp + 1)
.destinationServiceId(destination.toString())
.destinationServiceIdBinary(destination.toByteString())
@@ -354,11 +354,11 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onExpandEvents(messageId: Long) {
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onCollapseEvents(messageId: Long) {
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
}
+1 -1
View File
@@ -482,7 +482,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name="org.signal.mediasend.MediaSendActivity"
android:name=".mediasend.v3.MediaSendV3Activity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"
android:launchMode="singleTop"
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob;
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
@@ -102,12 +103,14 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -214,7 +217,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addNonBlocking(this::ensureProfileUploaded)
.addNonBlocking(() -> AppDependencies.getExpireStoriesManager().scheduleIfNecessary())
.addNonBlocking(BackupRepository::maybeFixAnyDanglingUploadProgress)
.addNonBlocking(BackupRepository::maybeFixAnyDanglingLocalExportProgress)
.addPostRender(() -> AppDependencies.getDeletedCallEventManager().scheduleIfNecessary())
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
.addPostRender(this::initializeExpiringMessageManager)
@@ -227,6 +229,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
@@ -401,6 +404,20 @@ public class ApplicationContext extends Application implements AppForegroundObse
AppDependencies.init(this, new ApplicationDependencyProvider(this));
}
AppForegroundObserver.begin();
if (Environment.USE_NEW_REGISTRATION) {
initializeRegistrationDependencies();
}
}
private void initializeRegistrationDependencies() {
org.signal.registration.RegistrationDependencies.Companion.provide(
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
)
);
}
private void initializeFirstEverAppLaunch() {
@@ -148,7 +148,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onViewPollClicked(long messageId);
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
void onViewPinnedMessage(long messageId);
void onExpandEvents(long messageId);
void onCollapseEvents(long messageId);
void onExpandEvents(long messageId, @NonNull View itemView, int collapsedSize);
void onCollapseEvents(long messageId, @NonNull View itemView, int collapsedSize);
}
}
@@ -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,8 +161,9 @@ import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -269,7 +272,7 @@ class MainActivity :
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
@@ -296,7 +299,7 @@ class MainActivity :
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
mediaSendLauncher = mediaSendLauncher()
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
@@ -342,6 +345,19 @@ class MainActivity :
}
}
}
launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ArchiveRestoreProgress
.stateFlow
.filter { it.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE }
.collect {
ArchiveRestoreProgress.clearLocalRestoreDirectoryError()
CouldNotCompleteBackupRestoreSheet().show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
Log.i(TAG, "Local restore directory became unavailable.")
}
}
}
}
supportFragmentManager.setFragmentResultListener(
@@ -1109,7 +1125,7 @@ class MainActivity :
if (isForQuickRestore) {
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
} else if (SignalStore.internal.useNewMediaActivity) {
mediaActivityLauncher.launch(
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isStory = destination == MainNavigationListLocation.STORIES
@@ -1125,7 +1141,7 @@ class MainActivity :
}
}
if (CameraXUtil.isSupported()) {
if (CameraXRemoteConfig.isSupported()) {
onGranted()
} else {
Permissions.with(this@MainActivity)
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
startActivity(intent);
finish();
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
startActivity(intent);
} else {
startActivity(intent);
finish();
}
}
}
@@ -173,7 +178,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_ENTER_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {
} else if (userMustCreateSignalPin() && getClass() != CreateSvrPinActivity.class) {
return STATE_CREATE_SIGNAL_PIN;
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
return STATE_TRANSFER_ONGOING;
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this);
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
}
private Intent getEnterSignalPinIntent() {
@@ -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)
@@ -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
@@ -428,6 +424,12 @@ object BackupRepository {
}
fun markOutOfRemoteStorageSpaceError() {
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
return
}
SignalStore.backup.markNotEnoughRemoteStorageSpace()
val context = AppDependencies.application
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
@@ -440,8 +442,6 @@ object BackupRepository {
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification)
SignalStore.backup.markNotEnoughRemoteStorageSpace()
}
fun clearOutOfRemoteStorageSpaceError() {
@@ -593,14 +593,6 @@ object BackupRepository {
SignalStore.backup.snoozeDownloadNotifier()
}
@JvmStatic
fun maybeFixAnyDanglingLocalExportProgress() {
if (!SignalStore.backup.newLocalBackupProgress.isIdle && AppDependencies.jobManager.find { it.factoryKey == LocalArchiveJob.KEY }.isEmpty()) {
Log.w(TAG, "Found stale local backup progress with no active job. Resetting to idle.")
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
}
}
@JvmStatic
fun maybeFixAnyDanglingUploadProgress() {
if (SignalStore.account.isLinkedDevice) {
@@ -1649,6 +1641,13 @@ object BackupRepository {
}
}
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
}
}
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): NetworkResult<Unit> {
return initBackupAndFetchAuth()
.then { credential ->
@@ -1688,7 +1687,6 @@ object BackupRepository {
/**
* Retrieves an [AttachmentUploadForm] that can be used to upload an attachment to the transit cdn.
* To continue the upload, use [org.whispersystems.signalservice.api.attachment.AttachmentApi.getResumableUploadSpec].
*
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
*/
@@ -1726,10 +1724,10 @@ object BackupRepository {
/**
* Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn.
*/
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
return initBackupAndFetchAuth()
.then { credential ->
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
val request = buildArchiveMediaRequest(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
SignalNetwork.archive.copyAttachmentToArchive(
aci = SignalStore.account.requireAci(),
@@ -1746,7 +1744,7 @@ object BackupRepository {
return initBackupAndFetchAuth()
.then { credential ->
val mediaName = attachment.requireMediaName()
val request = attachment.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
val request = buildArchiveMediaRequest(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, mediaName, credential.mediaBackupAccess.backupKey)
SignalNetwork.archive
.copyAttachmentToArchive(
aci = SignalStore.account.requireAci(),
@@ -2197,15 +2195,15 @@ object BackupRepository {
val profileKey: ProfileKey
)
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
private fun buildArchiveMediaRequest(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName)
return ArchiveMediaRequest(
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
cdn = cdn.cdnNumber,
key = remoteLocation!!
cdn = cdnNumber,
key = remoteLocation
),
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)).toInt(),
mediaId = mediaSecrets.id.encode(),
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
encryptionKey = Base64.encodeWithPadding(mediaSecrets.aesKey)
@@ -2618,3 +2616,9 @@ class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMed
)
}
}
data class UploadedThumbnailInfo(
val cdnNumber: Int,
val remoteLocation: String,
val size: Long
)
@@ -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
@@ -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)
}
}
@@ -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
}
}
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import java.util.concurrent.Executor
import kotlin.math.max
import kotlin.math.min
@@ -67,7 +68,7 @@ class CallEventCache(
val output = mutableListOf<CallLogRow.Call>()
val groupCallStateMap = mutableMapOf<Long, CallLogRow.GroupCallState>()
val canUserBeginCallMap = mutableMapOf<Long, Boolean>()
val canUserBeginCallMap = mutableMapOf<Long, CallLogRow.CanStartCall>()
val callLinksSeen = hashSetOf<Long>()
while (recordIterator.hasNext()) {
@@ -85,7 +86,7 @@ class CallEventCache(
private fun ListIterator<CacheRecord>.readNextCallLog(
filterState: FilterState,
groupCallStateMap: MutableMap<Long, CallLogRow.GroupCallState>,
canUserBeginCallMap: MutableMap<Long, Boolean>,
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>,
callLinksSeen: MutableSet<Long>
): CallLogRow.Call? {
val parent = next()
@@ -143,14 +144,16 @@ class CallEventCache(
return (child.timestamp - parent.timestamp) <= 4.hours.inWholeMilliseconds
}
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): Boolean {
return if (peer.isGroup && decryptedGroup != null) {
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): CallLogRow.CanStartCall {
if (peer.isGroup && decryptedGroup != null) {
val proto = DecryptedGroup.ADAPTER.decode(decryptedGroup)
return proto.isAnnouncementGroup != EnabledState.ENABLED ||
proto.members.firstOrNull() { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role == Member.Role.ADMINISTRATOR
} else {
true
when {
proto.terminated -> return CallLogRow.CanStartCall.GROUP_TERMINATED
DecryptedGroupUtil.findMemberByAci(proto.members, SignalStore.account.requireAci()).isEmpty -> return CallLogRow.CanStartCall.NOT_A_MEMBER
proto.isAnnouncementGroup == EnabledState.ENABLED && proto.members.firstOrNull { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role != Member.Role.ADMINISTRATOR -> return CallLogRow.CanStartCall.ADMIN_ONLY
}
}
return CallLogRow.CanStartCall.ALLOWED
}
private fun getGroupCallState(body: String?): CallLogRow.GroupCallState {
@@ -167,7 +170,7 @@ class CallEventCache(
children: Set<Long>,
filterState: FilterState,
groupCallStateCache: MutableMap<Long, CallLogRow.GroupCallState>,
canUserBeginCallMap: MutableMap<Long, Boolean>
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>
): CallLogRow.Call {
val peer = Recipient.resolved(RecipientId.from(parent.peer))
return CallLogRow.Call(
@@ -195,10 +198,10 @@ class CallEventCache(
searchQuery = filterState.query,
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
canUserBeginCall = if (peer.isGroup) {
if (peer.isActiveGroup) {
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
} else false
} else true
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
} else {
CallLogRow.CanStartCall.ALLOWED
}
)
}
@@ -223,7 +223,7 @@ class CallLogAdapter(
binding: CallLogAdapterItemBinding,
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallLinkModel) {
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
@@ -280,7 +280,7 @@ class CallLogAdapter(
}
)
binding.groupCallButton.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient, true)
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
}
binding.callType.visible = false
binding.groupCallButton.visible = true
@@ -288,7 +288,7 @@ class CallLogAdapter(
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient, true)
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
}
binding.callType.visible = true
binding.groupCallButton.visible = false
@@ -301,7 +301,7 @@ class CallLogAdapter(
private val onCallClicked: (CallLogRow.Call) -> Unit,
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
private val onStartAudioCallClicked: (Recipient) -> Unit,
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallModel) {
itemView.setOnClickListener {
@@ -401,7 +401,7 @@ class CallLogAdapter(
CallTable.Type.VIDEO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, true) }
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, CallLogRow.CanStartCall.ALLOWED) }
binding.callType.visible = true
binding.groupCallButton.visible = false
}
@@ -574,6 +574,6 @@ class CallLogAdapter(
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean)
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall)
}
}
@@ -364,18 +364,21 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
if (canUserBeginCall) {
CommunicationActions.startVideoCall(this, recipient) {
mainNavigationViewModel.snackbarRegistry.emit(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call),
hostKey = MainSnackbarHostKey.MainChrome
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall) {
when (canUserBeginCall) {
CallLogRow.CanStartCall.ALLOWED -> {
CommunicationActions.startVideoCall(this, recipient) {
mainNavigationViewModel.snackbarRegistry.emit(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call),
hostKey = MainSnackbarHostKey.MainChrome
)
)
)
}
}
} else {
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
}
}
@@ -41,7 +41,7 @@ sealed class CallLogRow {
val children: Set<Long>,
val searchQuery: String?,
val callLinkPeekInfo: CallLinkPeekInfo?,
val canUserBeginCall: Boolean,
val canUserBeginCall: CanStartCall,
override val id: Id = Id.Call(children)
) : CallLogRow()
@@ -111,4 +111,11 @@ sealed class CallLogRow {
}
}
}
enum class CanStartCall {
ALLOWED,
ADMIN_ONLY,
NOT_A_MEMBER,
GROUP_TERMINATED
}
}
@@ -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);
}
@@ -32,14 +32,14 @@ import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Applies Signal or System emoji to the given content based off user settings.
* Applies Signal or System emoji to the given content based on user settings.
*
* Text is transformed and passed to content as an annotated string and inline content map.
*/
@Composable
fun Emojifier(
text: String,
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
useSystemEmoji: Boolean = LocalInspectionMode.current || SignalStore.settings.isPreferSystemEmoji,
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
Text(
text = annotatedText,
@@ -82,6 +82,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
appSettingsRoute.threadIds
)
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
@@ -214,6 +215,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmOverloads
fun remoteBackups(context: Context, forQuickRestore: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote(forQuickRestore = forQuickRestore))
@JvmStatic
fun chats(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatsRoute.Chats)
@JvmStatic
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)
@@ -414,19 +414,8 @@ private fun AppSettingsContent(
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences_chats__backups),
style = MaterialTheme.typography.bodyLarge
)
},
icon = {
Icon(
imageVector = SignalIcons.Backup.imageVector,
contentDescription = stringResource(R.string.preferences_chats__backups),
tint = MaterialTheme.colorScheme.onSurface
)
},
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
},
@@ -18,6 +18,7 @@ import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.BackupPassphrase
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -74,7 +75,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
viewModelScope.launch {
SignalStore.backup.newLocalBackupProgressFlow.collect { progress ->
LocalExportProgress.encryptedProgress.collect { progress ->
internalSettingsState.update { it.copy(progress = progress) }
}
}
@@ -108,7 +109,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
fun onBackupStarted() {
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE))
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE)))
}
fun turnOffAndDelete(context: Context) {
@@ -335,7 +335,7 @@ class ChangeNumberRepository(
} else {
PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
}
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
// Last-resort kyber prekeys
val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) {
@@ -343,7 +343,7 @@ class ChangeNumberRepository(
} else {
PreKeyUtil.generateLastResortKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
}
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id.toLong(), lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
// Registration Ids
var pniRegistrationId = -1
@@ -383,8 +383,8 @@ class ChangeNumberRepository(
previousPni = SignalStore.account.pni!!.toByteString(),
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId,
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId,
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId.toInt(),
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId.toInt(),
previousE164 = SignalStore.account.requireE164(),
newE164 = newE164
)
@@ -0,0 +1,137 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Launchers
import org.thoughtcrime.securesms.R
/**
* Dialogs displayed while processing a user's decrypted chat export.
*
* Displayed *after* the user has confirmed via phone auth.
*/
@Composable
fun ChatExportDialogs(state: ChatExportState, callbacks: ChatExportCallbacks) {
val folderPicker = Launchers.rememberOpenDocumentTreeLauncher {
if (it != null) {
callbacks.onFolderSelected(it)
} else {
callbacks.onCancelStartExport()
}
}
when (state) {
ChatExportState.None -> Unit
ChatExportState.ConfirmExport -> ConfirmExportDialog(
onConfirmExport = callbacks::onConfirmExport,
onCancel = callbacks::onCancelStartExport
)
ChatExportState.ChooseAFolder -> ChooseAFolderDialog(
onChooseAFolder = { folderPicker.launch(null) },
onCancel = callbacks::onCancelStartExport
)
ChatExportState.Canceling -> Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.ChatExportDialogs__canceling_export))
ChatExportState.Success -> CompleteDialog(
onOK = callbacks::onCompletionConfirmed
)
}
}
@Composable
private fun ConfirmExportDialog(
onConfirmExport: (withMedia: Boolean) -> Unit,
onCancel: () -> Unit
) {
val body = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.ChatExportDialogs__be_careful_warning))
}
append(" ")
append(stringResource(R.string.ChatExportDialogs__export_confirm_body))
}
Dialogs.AdvancedAlertDialog(
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_chat_history_title)),
body = body,
positive = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_with_media)),
neutral = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_without_media)),
negative = AnnotatedString(stringResource(android.R.string.cancel)),
onPositive = { onConfirmExport(true) },
onNeutral = { onConfirmExport(false) },
onNegative = onCancel
)
}
@Composable
private fun ChooseAFolderDialog(
onChooseAFolder: () -> Unit,
onCancel: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.ChatExportDialogs__choose_a_folder_title),
body = stringResource(R.string.ChatExportDialogs__choose_a_folder_body),
confirm = stringResource(R.string.ChatExportDialogs__choose_folder_button),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onChooseAFolder,
onDeny = onCancel
)
}
@Composable
private fun CompleteDialog(
onOK: () -> Unit
) {
val body = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.ChatExportDialogs__be_careful))
}
append(" ")
append(stringResource(R.string.ChatExportDialogs__complete_body))
}
Dialogs.SimpleAlertDialog(
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__complete_title)),
body = body,
confirm = AnnotatedString(stringResource(android.R.string.ok)),
onConfirm = onOK
)
}
enum class ChatExportState {
None,
ConfirmExport,
ChooseAFolder,
Canceling,
Success
}
interface ChatExportCallbacks {
fun onConfirmExport(withMedia: Boolean)
fun onFolderSelected(uri: Uri)
fun onCancelStartExport()
fun onCompletionConfirmed()
object Empty : ChatExportCallbacks {
override fun onConfirmExport(withMedia: Boolean) = Unit
override fun onFolderSelected(uri: Uri) = Unit
override fun onCancelStartExport() = Unit
override fun onCompletionConfirmed() = Unit
}
}
@@ -1,16 +1,20 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
@@ -18,9 +22,14 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.isIdle
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -79,10 +88,38 @@ class ChatsSettingsFragment : ComposeFragment() {
override fun onEnterKeySendsChanged(enabled: Boolean) {
viewModel.setEnterKeySends(enabled)
}
override fun onExportPlaintextChatHistoryClick() {
viewModel.requestChatExportType()
}
override fun onCancelInFlightExport() {
viewModel.cancelChatExport()
}
// region ChatExportCallback
override fun onConfirmExport(withMedia: Boolean) {
viewModel.setExportTypeAndGoToSelectFolder(withMedia)
}
override fun onFolderSelected(uri: Uri) {
viewModel.startChatExportToFolder(uri)
}
override fun onCancelStartExport() {
viewModel.clearChatExportFlow()
}
override fun onCompletionConfirmed() {
viewModel.clearChatExportFlow()
}
// endregion
}
}
private interface ChatsSettingsCallbacks {
private interface ChatsSettingsCallbacks : ChatExportCallbacks {
fun onNavigationClick() = Unit
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
fun onUseAddressBookChanged(enabled: Boolean) = Unit
@@ -91,8 +128,10 @@ private interface ChatsSettingsCallbacks {
fun onAddOrEditFoldersClick() = Unit
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
fun onExportPlaintextChatHistoryClick() = Unit
fun onCancelInFlightExport() = Unit
object Empty : ChatsSettingsCallbacks
object Empty : ChatsSettingsCallbacks, ChatExportCallbacks by ChatExportCallbacks.Empty
}
@Composable
@@ -100,10 +139,25 @@ private fun ChatsSettingsScreen(
state: ChatsSettingsState,
callbacks: ChatsSettingsCallbacks
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val authenticationFailedMessage = stringResource(R.string.ChatsSettingsFragment__authentication_failed)
val plaintextBiometricsAuthentication = rememberBiometricsAuthentication(
promptTitle = stringResource(R.string.ChatsSettingsFragment__unlock_to_export_chat_history),
onAuthenticationFailed = {
coroutineScope.launch {
snackbarHostState.showSnackbar(authenticationFailedMessage)
}
}
)
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__chats),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector
navigationIcon = SignalIcons.ArrowStart.imageVector,
snackbarHost = {
Snackbars.Host(snackbarHostState)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
@@ -167,6 +221,36 @@ private fun ChatsSettingsScreen(
}
}
if (state.isPlaintextExportEnabled) {
item {
Dividers.Default()
}
if (state.plaintextExportProgress.isIdle) {
item(key = "export_chat_history_row") {
Rows.TextRow(
modifier = Modifier.animateItem(),
text = stringResource(R.string.ChatsSettingsFragment__export_chat_history),
label = stringResource(R.string.ChatsSettingsFragment__export_chat_history_label),
onClick = {
plaintextBiometricsAuthentication.withBiometricsAuthentication {
callbacks.onExportPlaintextChatHistoryClick()
}
}
)
}
} else {
item(key = "export_chat_history_progress") {
BackupCreationProgressRow(
modifier = Modifier.animateItem(),
progress = state.plaintextExportProgress,
isRemote = false,
onCancel = callbacks::onCancelInFlightExport
)
}
}
}
item {
Dividers.Default()
}
@@ -194,6 +278,13 @@ private fun ChatsSettingsScreen(
}
}
}
if (state.isPlaintextExportEnabled) {
ChatExportDialogs(
state = state.chatExportState,
callbacks = callbacks
)
}
}
@DayNightPreviews
@@ -210,7 +301,9 @@ private fun ChatsSettingsScreenPreview() {
localBackupsEnabled = true,
folderCount = 1,
userUnregistered = false,
clientDeprecated = false
clientDeprecated = false,
isPlaintextExportEnabled = true,
plaintextExportProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
),
callbacks = ChatsSettingsCallbacks.Empty
)
@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
data class ChatsSettingsState(
val generateLinkPreviews: Boolean,
val useAddressBook: Boolean,
@@ -9,7 +12,11 @@ data class ChatsSettingsState(
val localBackupsEnabled: Boolean,
val folderCount: Int,
val userUnregistered: Boolean,
val clientDeprecated: Boolean
val clientDeprecated: Boolean,
val isPlaintextExportEnabled: Boolean,
val plaintextExportProgress: LocalBackupCreationProgress = LocalExportProgress.plaintextProgress.value,
val chatExportState: ChatExportState = ChatExportState.None,
val includeMediaInExport: Boolean = false
) {
fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -7,11 +8,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ThrottledDebouncer
@@ -31,12 +35,53 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
folderCount = 0,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
clientDeprecated = SignalStore.misc.isClientDeprecated
clientDeprecated = SignalStore.misc.isClientDeprecated,
isPlaintextExportEnabled = RemoteConfig.localPlaintextExport,
chatExportState = ChatExportState.None
)
)
val state: StateFlow<ChatsSettingsState> = store
init {
viewModelScope.launch {
LocalExportProgress.plaintextProgress.collect { progress ->
store.update {
it.copy(
plaintextExportProgress = progress,
chatExportState = when {
progress.succeeded != null && it.plaintextExportProgress.succeeded == null -> ChatExportState.Success
progress.canceled != null -> ChatExportState.None
else -> it.chatExportState
}
)
}
}
}
}
fun requestChatExportType() {
store.update { it.copy(chatExportState = ChatExportState.ConfirmExport) }
}
fun setExportTypeAndGoToSelectFolder(includeMediaInExport: Boolean) {
store.update { it.copy(chatExportState = ChatExportState.ChooseAFolder, includeMediaInExport = includeMediaInExport) }
}
fun startChatExportToFolder(uri: Uri) {
store.update { it.copy(chatExportState = ChatExportState.None) }
LocalBackupJob.enqueuePlaintextArchive(uri.toString(), store.value.includeMediaInExport)
}
fun clearChatExportFlow() {
store.update { it.copy(chatExportState = ChatExportState.None, includeMediaInExport = false) }
}
fun cancelChatExport() {
store.update { it.copy(chatExportState = ChatExportState.Canceling) }
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.PLAINTEXT_ARCHIVE_QUEUE)
}
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
store.update { it.copy(generateLinkPreviews = enabled) }
SignalStore.settings.isLinkPreviewsEnabled = enabled
@@ -235,6 +235,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
title = DSLSettingsText.from("Collapse chat updates"),
summary = DSLSettingsText.from("Collapses certain consecutive chat updates - cannot be undone."),
onClick = {
SignalStore.misc.completedCollapsedEventsMigration = false
AppDependencies.jobManager.add(BackfillCollapsedMessageJob())
}
)
@@ -73,6 +73,7 @@ import org.signal.core.util.Util
import org.signal.core.util.getLength
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.backup.isIdle
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
@@ -270,6 +271,10 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
.setNegativeButton("Cancel", null)
.show()
},
onTriggerLocalRestoreDirectoryError = {
SignalStore.backup.localRestoreDirectoryError = true
ArchiveRestoreProgress.forceUpdate()
},
onDisplayInitialBackupFailureSheet = {
BackupRepository.displayInitialBackupFailureNotification()
BackupAlertBottomSheet
@@ -366,6 +371,7 @@ fun Screen(
onImportEncryptedBackupFromDiskConfirmed: (aci: String, backupKey: String) -> Unit = { _, _ -> },
onClearLocalMediaBackupState: () -> Unit = {},
onDeleteRemoteBackup: () -> Unit = {},
onTriggerLocalRestoreDirectoryError: () -> Unit = {},
onDisplayInitialBackupFailureSheet: () -> Unit = {}
) {
val context = LocalContext.current
@@ -584,6 +590,12 @@ fun Screen(
onClick = onClearLocalMediaBackupState
)
Rows.TextRow(
text = "Trigger local restore directory error",
label = "Simulates the restore directory becoming inaccessible during a local backup restore.",
onClick = onTriggerLocalRestoreDirectoryError
)
Dividers.Default()
Rows.TextRow(
@@ -40,6 +40,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
@@ -92,7 +93,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
init {
viewModelScope.launch {
SignalStore.backup.newLocalPlaintextBackupProgressFlow.collect { progress ->
LocalExportProgress.plaintextProgress.collect { progress ->
_state.value = _state.value.copy(plaintextProgress = progress)
}
}
@@ -68,7 +68,6 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
private fun setDiscoverableByPhoneNumber(discoverable: Boolean) {
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (discoverable) PhoneNumberDiscoverabilityMode.DISCOVERABLE else PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
SignalDatabase.recipients.clearSelfKeyTransparencyData()
StorageSyncHelper.scheduleSyncForDataChange()
AppDependencies.jobManager.startChain(RefreshAttributesJob()).then(RefreshOwnProfileJob()).enqueue()
refresh()
@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
timestamp = insertedAt.inWholeMilliseconds,
error = null,
pendingVerification = true,
checkedVerification = data.waitForAuth!!.checkedVerification
checkedVerification = data.waitForAuth?.checkedVerification ?: false
)
}
@@ -26,7 +26,7 @@ import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.qr.QrScannerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.qr.QrScanScreens
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.concurrent.TimeUnit
@@ -98,7 +98,7 @@ fun UsernameQrScanScreen(
view
},
update = { view ->
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted())
},
hasPermission = hasCameraPermission,
onRequestPermissions = onOpenCameraClicked,
@@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
@@ -486,7 +486,7 @@ class ConversationSettingsFragment :
.setMessage(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
} else if (CameraXUtil.isSupported()) {
} else if (CameraXRemoteConfig.isSupported()) {
addToGroupStoryDelegate.addToStory(state.recipient.id)
} else {
Permissions.with(this@ConversationSettingsFragment)
@@ -718,7 +718,9 @@ class ConversationSettingsFragment :
mediaRecords = state.sharedMedia,
mediaIds = state.sharedMediaIds,
onMediaRecordClick = { view, mediaRecord, isLtr ->
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE) {
if (mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
mediaRecord.attachment?.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
) {
Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show()
return@Model
}
@@ -66,8 +66,8 @@ object CallPreference {
MessageTypes.MISSED_VIDEO_CALL_TYPE -> getMissedCallString(true, call.event)
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(false, call.event) else R.string.MessageRecord_incoming_voice_call
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(true, call.event) else R.string.MessageRecord_incoming_video_call
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.MessageRecord_outgoing_voice_call
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.MessageRecord_outgoing_video_call
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_voice_call else R.string.MessageRecord_outgoing_voice_call
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_video_call else R.string.MessageRecord_outgoing_video_call
MessageTypes.GROUP_CALL_TYPE -> when {
call.isDisplayedAsMissedCallInUi -> if (call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE) R.string.CallPreference__missed_group_call_notification_profile else R.string.CallPreference__missed_group_call
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
@@ -14,6 +14,7 @@ import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
/**
@@ -35,6 +36,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
.setHandleAudioBecomingNoisy(true).build()
) : ForwardingPlayer(internalPlayer) {
companion object {
private val TAG = Log.tag(VoiceNotePlayer::class.java)
}
init {
val audioManager = ContextCompat.getSystemService(context, AudioManager::class.java)
@@ -47,6 +52,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
.build()
)
.setOnAudioFocusChangeListener {
if (it == AudioManager.AUDIOFOCUS_LOSS || it == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
Log.d(TAG, "Audio focus change to $it. Pausing.")
this.pause()
}
}
.build()
} else {
@@ -207,6 +207,8 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
player.playWhenReady = true
} else {
Log.i(TAG, "Audio stream set to $newStreamType. Not playing when ready.")
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
@@ -15,7 +15,9 @@ import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionCommand
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(VoiceNoteProximityWakeLockManager::class.java)
@@ -31,6 +33,7 @@ class VoiceNoteProximityWakeLockManager(
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
private val audioManager: AudioManagerCompat = AppDependencies.androidCallAudioManager
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
@@ -58,7 +61,7 @@ class VoiceNoteProximityWakeLockManager(
}
fun unregisterCallbacksAndRelease() {
mediaController.addListener(mediaControllerCallback)
mediaController.removeListener(mediaControllerCallback)
cleanUpWakeLock()
}
@@ -91,20 +94,24 @@ class VoiceNoteProximityWakeLockManager(
inner class ProximityListener : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_IS_PLAYING_CHANGED)) {
if (!isActivityResumed()) {
return
}
if (player.isPlaying) {
if (startTime == -1L) {
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
startTime = System.currentTimeMillis()
if (wakeLock?.isHeld == false) {
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
if (audioManager.isHeadsetConnected) {
Log.d(TAG, "[onPlaybackStateChanged] Headset connected, skipping proximity sensor registration.")
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
startTime = System.currentTimeMillis()
if (wakeLock?.isHeld == false) {
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
}
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
}
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
}
@@ -118,11 +125,14 @@ class VoiceNoteProximityWakeLockManager(
inner class HardwareSensorEventListener : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
if (startTime == -1L ||
System.currentTimeMillis() - startTime <= 500 ||
if (System.currentTimeMillis() - startTime <= 500) {
Log.i(TAG, "Ignoring sensor change because it's too close to start time.")
return
} else if (startTime == -1L ||
!isActivityResumed() ||
!mediaController.isPlaying ||
event.sensor.type != Sensor.TYPE_PROXIMITY
event.sensor.type != Sensor.TYPE_PROXIMITY ||
audioManager.isHeadsetConnected()
) {
return
}
@@ -358,7 +358,7 @@ data class CallParticipantsState(
@PluralsRes multipleParticipants: Int,
members: List<GroupMemberEntry.FullMember>
): String {
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked }
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked || it.member.isUnregistered }
return when (eligibleMembers.size) {
0 -> noParticipants?.let { context.getString(noParticipants) } ?: ""
@@ -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());
}
}
@@ -12,7 +12,9 @@ import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -125,11 +127,23 @@ public class ConversationRepository {
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return Single.fromCallable(() -> {
MessageRecord messageRecord = message.getMessageRecord();
ConversationMessage latestMessage = message;
MessageRecord messageRecord = latestMessage.getMessageRecord();
MessageId latestRevisionId = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getLatestRevisionId() : null;
if (latestRevisionId != null) {
MessageRecord latestRecord = SignalDatabase.messages().getMessageRecordOrNull(latestRevisionId.getId());
if (latestRecord != null) {
Log.e(TAG, "Resolving edit to latest revision: " + latestRevisionId.getId() + " (was: " + messageRecord.getId() + ")");
messageRecord = latestRecord;
latestMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context).toString(), message.getThreadRecipient());
}
}
if (MessageRecordUtil.hasTextSlide(messageRecord)) {
TextSlide textSlide = MessageRecordUtil.requireTextSlide(messageRecord);
if (textSlide.getUri() == null) {
return message;
return latestMessage;
}
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
@@ -139,7 +153,7 @@ public class ConversationRepository {
Log.w(TAG, "Failed to read text slide data.");
}
}
return message;
return latestMessage;
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
@@ -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());
};
}
@@ -47,6 +47,7 @@ object PlaintextExportRepository {
threadId: Long,
directoryUri: Uri,
chatName: String,
includeMedia: Boolean,
progressListener: ProgressListener,
cancellationSignal: CancellationSignal
): Boolean {
@@ -70,9 +71,13 @@ object PlaintextExportRepository {
return false
}
val mediaDir = chatDir.createDirectory("media") ?: run {
Log.w(TAG, "Could not create media directory")
return false
val mediaDir = if (includeMedia) {
chatDir.createDirectory("media") ?: run {
Log.w(TAG, "Could not create media directory")
return false
}
} else {
null
}
val chatFile = chatDir.createFile("text/plain", "chat.txt") ?: run {
@@ -117,11 +122,15 @@ object PlaintextExportRepository {
for (message in batch) {
if (cancellationSignal.isCancelled()) return false
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments)
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments, includeMedia)
writer.newLine()
messagesProcessed++
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
if (includeMedia) {
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
} else {
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, 0)
}
}
eventTimer.emit("messages")
}
@@ -136,32 +145,34 @@ object PlaintextExportRepository {
// Attachments — use createFile directly (like LocalArchiver's FilesFileSystem) to avoid
// the extra content resolver queries that newFile/findFile perform.
val totalAttachments = pendingAttachments.size
var attachmentsProcessed = 0
for (pending in pendingAttachments) {
if (cancellationSignal.isCancelled()) return false
if (includeMedia && mediaDir != null) {
val totalAttachments = pendingAttachments.size
var attachmentsProcessed = 0
for (pending in pendingAttachments) {
if (cancellationSignal.isCancelled()) return false
try {
val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) }
if (outputStream == null) {
Log.w(TAG, "Could not create attachment file: ${pending.exportedName}")
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
continue
}
outputStream.use { out ->
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
input.copyTo(out)
try {
val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) }
if (outputStream == null) {
Log.w(TAG, "Could not create attachment file: ${pending.exportedName}")
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
continue
}
}
} catch (e: Exception) {
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
}
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
eventTimer.emit("media")
outputStream.use { out ->
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
input.copyTo(out)
}
}
} catch (e: Exception) {
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
}
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
eventTimer.emit("media")
}
}
Log.d(TAG, "[PlaintextExport] ${eventTimer.stop().summary}")
@@ -222,7 +233,8 @@ object PlaintextExportRepository {
extraData: ExtraMessageData,
dateFormat: SimpleDateFormat,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
includeMedia: Boolean
) {
val timestamp = dateFormat.format(Date(message.dateSent))
@@ -262,7 +274,7 @@ object PlaintextExportRepository {
}
if (stickerAttachment != null) {
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments)
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments, includeMedia)
return
}
@@ -282,7 +294,7 @@ object PlaintextExportRepository {
}
val wrotePrefix = !body.isNullOrEmpty() || hasQuote
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments)
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments, includeMedia)
}
private fun BufferedWriter.writeUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String) {
@@ -323,15 +335,20 @@ object PlaintextExportRepository {
prefix: String,
hasQuote: Boolean,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
includeMedia: Boolean
) {
val emoji = stickerAttachment.stickerLocator?.emoji ?: ""
val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName))
if (!hasQuote) {
this.write(prefix)
}
this.write("(Sticker) $emoji [See: media/$exportedName]")
if (includeMedia) {
val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName))
this.write("(Sticker) $emoji [See: media/$exportedName]")
} else {
this.write("(Sticker) $emoji")
}
this.newLine()
}
@@ -340,23 +357,33 @@ object PlaintextExportRepository {
prefix: String,
wrotePrefix: Boolean,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
includeMedia: Boolean
) {
for ((index, attachment) in attachments.withIndex()) {
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(attachment, exportedName))
val label = getAttachmentLabel(attachment)
if (!wrotePrefix && index == 0) {
this.write(prefix)
}
val caption = attachment.caption
if (caption != null) {
this.write("[$label: media/$exportedName] $caption")
if (includeMedia) {
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(attachment, exportedName))
val caption = attachment.caption
if (caption != null) {
this.write("[$label: media/$exportedName] $caption")
} else {
this.write("[$label: media/$exportedName]")
}
} else {
this.write("[$label: media/$exportedName]")
val caption = attachment.caption
if (caption != null) {
this.write("[$label] $caption")
} else {
this.write("[$label]")
}
}
this.newLine()
}
@@ -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) }
}
@@ -33,6 +33,20 @@ object ConversationDialogs {
.show()
}
fun displayCannotStartGroupCallDueToNoLongerAMemberDialog(context: Context) {
MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.CallLogFragment__cant_start_call_no_longer_a_member)
.setPositiveButton(android.R.string.ok) { d: DialogInterface, _: Int -> d.dismiss() }
.show()
}
fun displayCannotStartGroupCallDueToGroupEndedDialog(context: Context) {
MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
.setPositiveButton(android.R.string.ok) { d: DialogInterface, _: Int -> d.dismiss() }
.show()
}
fun displayChatSessionRefreshLearnMoreDialog(context: Context) {
MaterialAlertDialogBuilder(context)
.setView(R.layout.decryption_failed_dialog)
@@ -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
@@ -93,6 +93,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -552,6 +553,7 @@ class ConversationFragment :
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
private lateinit var plaintextExportDirectoryLauncher: ActivityResultLauncher<Uri?>
private var exportWithMedia = false
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
private lateinit var adapter: ConversationAdapterV2
@@ -587,6 +589,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 +653,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 +717,6 @@ class ConversationFragment :
startActivity(MemberLabelActivity.createIntent(requireContext(), groupId))
}
ToolbarDependentMarginListener(binding.toolbar)
initializeMediaKeyboard()
binding.conversationVideoContainer.setClipToOutline(true)
@@ -724,8 +729,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 +816,6 @@ class ConversationFragment :
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ToolbarDependentMarginListener(binding.toolbar)
inlineQueryController.onWindowSizeClassChanged(resources.getWindowSizeClass())
}
@@ -1035,7 +1046,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 +1150,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
}
}
})
}
@@ -1301,10 +1326,11 @@ class ConversationFragment :
lifecycleScope.launch {
viewModel
.pinnedMessages
.combine(viewModel.wallpaper) { messages, wallpaper -> messages to wallpaper }
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.flowOn(Dispatchers.Main)
.collect {
presentPinnedMessage(it, args.wallpaper != null)
.collect { (messages, wallpaper) ->
presentPinnedMessage(pinnedMessages = messages, hasWallpaper = wallpaper != null)
}
}
@@ -1582,7 +1608,7 @@ class ConversationFragment :
if (uri != null) {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
viewModel.startPlaintextExport(requireContext().applicationContext, uri)
viewModel.startPlaintextExport(requireContext().applicationContext, uri, exportWithMedia)
}
}
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
@@ -1656,7 +1682,6 @@ class ConversationFragment :
presentConversationTitle(recipient)
presentChatColors(recipient.chatColors)
invalidateOptionsMenu()
updateMessageRequestAcceptedState(!viewModel.hasMessageRequestState)
}
@@ -2173,6 +2198,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)
@@ -3794,11 +3822,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)
}
@@ -3915,6 +3951,10 @@ class ConversationFragment :
selectedConversationModel,
object : OnHideListener {
override fun startHide(focusedView: View?) {
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) {
return
}
multiselectItemDecoration.hideShade(binding.conversationItemRecycler)
ViewUtil.fadeOut(binding.reactionsShade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
@@ -4251,7 +4291,19 @@ class ConversationFragment :
}
override fun handleExportChat() {
plaintextExportDirectoryLauncher.launch(null)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ChatExportDialogs__export_chat_history_title)
.setMessage(R.string.ChatExportDialogs__export_confirm_body)
.setPositiveButton(R.string.ChatExportDialogs__export_with_media) { _, _ ->
exportWithMedia = true
plaintextExportDirectoryLauncher.launch(null)
}
.setNeutralButton(R.string.ChatExportDialogs__export_without_media) { _, _ ->
exportWithMedia = false
plaintextExportDirectoryLauncher.launch(null)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
@@ -5151,25 +5203,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
@@ -5242,4 +5275,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()
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -102,6 +103,7 @@ import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.rx.RxStore
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration
/**
@@ -172,6 +174,8 @@ class ConversationViewModel(
val isPushAvailable: Boolean
get() = recipientSnapshot?.isRegistered == true && Recipient.self().isRegistered
val wallpaper: Flow<ChatWallpaper?> = recipient.asFlow().map { it.wallpaper }.distinctUntilChanged()
val wallpaperSnapshot: ChatWallpaper?
get() = recipientSnapshot?.wallpaper
@@ -216,7 +220,7 @@ class ConversationViewModel(
private val _plaintextExportState = MutableStateFlow<PlaintextExportState>(PlaintextExportState.None)
val plaintextExportState: StateFlow<PlaintextExportState> = _plaintextExportState
private val plaintextExportCancelled = java.util.concurrent.atomic.AtomicBoolean(false)
private val plaintextExportCancelled = AtomicBoolean(false)
init {
disposables += recipient
@@ -733,6 +737,10 @@ class ConversationViewModel(
}
}
fun resetBackPressedState() {
internalBackPressedState.value = BackPressedState()
}
fun toggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
val voteCount = if (isChecked) {
@@ -755,7 +763,7 @@ class ConversationViewModel(
}
}
fun startPlaintextExport(context: Context, directoryUri: Uri) {
fun startPlaintextExport(context: Context, directoryUri: Uri, withMedia: Boolean) {
val recipient = recipientSnapshot ?: return
val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context)
@@ -768,12 +776,17 @@ class ConversationViewModel(
threadId = threadId,
directoryUri = directoryUri,
chatName = chatName,
includeMedia = withMedia,
progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount ->
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
val percent = messagePercent + attachmentPercent
val percent = if (withMedia) {
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
messagePercent + attachmentPercent
} else {
if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100
}
val status = if (attachmentsProcessed > 0 || messagesProcessed >= messageCount) {
val status = if (withMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
} else {
"Exporting messages ($messagesProcessed/$messageCount)..."
@@ -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) {
@@ -168,7 +168,6 @@ import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter;
import org.thoughtcrime.securesms.verify.SelfVerificationFailureSheet;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.signal.core.ui.WindowSizeClassExtensionsKt;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
@@ -1305,15 +1304,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
private void handleCreateConversation(long threadId, Recipient recipient, int distributionType) {
SimpleTask.run(getLifecycle(), () -> {
ChatWallpaper wallpaper = recipient.resolve().getWallpaper();
if (wallpaper != null && !wallpaper.prefetch(requireContext(), 250)) {
Log.w(TAG, "Failed to prefetch wallpaper.");
}
return null;
}, (nothing) -> {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
});
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
}
private void handleOpenIncognito(@NonNull Conversation conversation) {
@@ -1321,15 +1312,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
Recipient recipient = conversation.getThreadRecord().getRecipient();
int distributionType = conversation.getThreadRecord().getDistributionType();
SimpleTask.run(getLifecycle(), () -> {
ChatWallpaper wallpaper = recipient.resolve().getWallpaper();
if (wallpaper != null && !wallpaper.prefetch(requireContext(), 250)) {
Log.w(TAG, "Failed to prefetch wallpaper.");
}
return null;
}, (nothing) -> {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1, true);
});
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1, true);
}
private void startActionModeIfNotActive() {
@@ -7,6 +7,8 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
*/
object CollapsibleEvents {
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
@@ -164,35 +164,24 @@ class LocalMetricsDatabase private constructor(
}
fun getMetrics(): List<EventMetrics> {
val db = readableDatabase
val events: Map<String, List<String>> = getUniqueEventNames()
db.beginTransaction()
try {
val events: Map<String, List<String>> = getUniqueEventNames()
val metrics: List<EventMetrics> = events.map { (eventName: String, splits: List<String>) ->
EventMetrics(
name = eventName,
count = getCount(eventName),
p50 = eventPercent(eventName, 50),
p90 = eventPercent(eventName, 90),
p99 = eventPercent(eventName, 99),
splits = splits.map { splitName ->
SplitMetrics(
name = splitName,
p50 = splitPercent(eventName, splitName, 50),
p90 = splitPercent(eventName, splitName, 90),
p99 = splitPercent(eventName, splitName, 99)
)
}
)
}
db.setTransactionSuccessful()
return metrics
} finally {
db.endTransaction()
return events.map { (eventName: String, splits: List<String>) ->
EventMetrics(
name = eventName,
count = getCount(eventName),
p50 = eventPercent(eventName, 50),
p90 = eventPercent(eventName, 90),
p99 = eventPercent(eventName, 99),
splits = splits.map { splitName ->
SplitMetrics(
name = splitName,
p50 = splitPercent(eventName, splitName, 50),
p90 = splitPercent(eventName, splitName, 90),
p99 = splitPercent(eventName, splitName, 99)
)
}
)
}
}
@@ -109,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
@@ -120,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
"""
@@ -129,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
@@ -203,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
)
@@ -232,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__", "")
}
}
@@ -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 {
@@ -1290,7 +1290,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
if (key == null) {
Log.w(TAG, "Needed to repair storageId for $recipientId (group $id)")
rotateStorageId(existing.id)
rotateStorageId(existing.id, logFailure = true)
existing = getRecordForSync(recipientId) ?: throw AssertionError("Failed to find recipient record for second fetch!")
key = existing.storageId ?: throw AssertionError("StorageId not present immediately after setting it!")
}
@@ -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)
@@ -4011,7 +4006,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
* Will *not* give storageIds to those that shouldn't get them (e.g. MMS groups, unregistered
* users).
*/
fun rotateStorageId(recipientId: RecipientId) {
fun rotateStorageId(recipientId: RecipientId, logFailure: Boolean = false) {
val selfId = Recipient.self().id
val values = ContentValues(1).apply {
@@ -4023,6 +4018,16 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
writableDatabase.update(TABLE_NAME, values, query, args).also { updateCount ->
Log.d(TAG, "[rotateStorageId] updateCount: $updateCount")
if (logFailure && updateCount == 0) {
val typeRegistered = readableDatabase
.select(TYPE, REGISTERED)
.from(TABLE_NAME)
.where(ID_WHERE, recipientId)
.run()
.readToSingleObject { it.requireInt(TYPE) to it.requireInt(REGISTERED) }
Log.w(TAG, "[rotateStorageId] No records updated for $recipientId, exists=${typeRegistered != null} type=${typeRegistered?.first} registered=${typeRegistered?.second}")
}
}
}
@@ -151,10 +151,11 @@ public final class ThreadBodyUtil {
if (call != null) {
boolean accepted = call.getEvent() == CallTable.Event.ACCEPTED;
if (call.getDirection() == CallTable.Direction.OUTGOING) {
if (call.getType() == CallTable.Type.AUDIO_CALL) {
return context.getString(R.string.MessageRecord_outgoing_voice_call);
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
if (call.getEvent() == CallTable.Event.NOT_ACCEPTED) {
return context.getString(isVideoCall ? R.string.MessageRecord_unanswered_video_call : R.string.MessageRecord_unanswered_voice_call);
} else {
return context.getString(R.string.MessageRecord_outgoing_video_call);
return context.getString(isVideoCall ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_outgoing_voice_call);
}
} else {
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
@@ -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()}"
@@ -260,12 +260,18 @@ public class MmsMessageRecord extends MessageRecord {
String callDateString = getCallDateString(context);
if (call.getDirection() == CallTable.Direction.OUTGOING) {
if (call.getType() == CallTable.Type.AUDIO_CALL) {
int updateString = R.string.MessageRecord_outgoing_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), Glyph.PHONE);
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
Glyph icon = isVideoCall ? Glyph.VIDEO_CAMERA : Glyph.PHONE;
if (call.getEvent() == CallTable.Event.NOT_ACCEPTED) {
int message = isVideoCall ? R.string.MessageRecord_unanswered_video_call : R.string.MessageRecord_unanswered_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(message), callDateString),
icon,
ContextCompat.getColor(context, R.color.core_red_shade),
ContextCompat.getColor(context, R.color.core_red));
} else {
int updateString = R.string.MessageRecord_outgoing_video_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), Glyph.VIDEO_CAMERA);
int updateString = isVideoCall ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_outgoing_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
}
} else {
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
@@ -344,6 +344,10 @@ object AppDependencies {
val linkDeviceApi: LinkDeviceApi
get() = networkModule.linkDeviceApi
@JvmStatic
val pushServiceSocket: PushServiceSocket
get() = networkModule.pushServiceSocket
@JvmStatic
val registrationApi: RegistrationApi
get() = networkModule.registrationApi
@@ -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();
@@ -141,6 +141,7 @@ object FcmFetchManager {
@JvmStatic
fun onForeground(context: Context) {
cancelMayHaveMessagesNotification(context)
FcmFetchForegroundService.stopServiceIfNecessary(context)
}
@JvmStatic
@@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -54,12 +55,16 @@ fun MemberLabelPill(
maxLines: Int = 1
) {
val isDark = isSystemInDarkTheme()
val backgroundColor = tintColor.copy(alpha = if (isDark) 0.32f else 0.10f)
val backgroundColor = remember(isDark, tintColor) {
tintColor.copy(alpha = if (isDark) 0.32f else 0.10f)
}
val textColor = if (isDark) {
Color.White.copy(alpha = 0.25f).compositeOver(tintColor)
} else {
Color.Black.copy(alpha = 0.30f).compositeOver(tintColor)
val textColor = remember(isDark, tintColor) {
if (isDark) {
Color.White.copy(alpha = 0.25f).compositeOver(tintColor)
} else {
Color.Black.copy(alpha = 0.30f).compositeOver(tintColor)
}
}
MemberLabelPill(
@@ -97,27 +97,38 @@ private fun SenderNameWithLabel(
modifier: Modifier = Modifier,
labelSlot: @Composable (MemberLabel) -> Unit
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically
) {
ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)) {
Emojifier(text = senderName) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent,
color = senderColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (memberLabel != null) {
if (memberLabel != null) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically
) {
SenderNameText(senderName, senderColor)
labelSlot(memberLabel)
}
} else {
SenderNameText(senderName, senderColor, modifier)
}
}
@Composable
private fun SenderNameText(
senderName: String,
senderColor: Color,
modifier: Modifier = Modifier
) {
ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)) {
Emojifier(text = senderName) { annotatedText, inlineContent ->
Text(
modifier = modifier,
text = annotatedText,
inlineContent = inlineContent,
color = senderColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -560,8 +560,10 @@ class GroupsV2StateProcessor private constructor(
}
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
if (!updatedGroupState.terminated) {
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
}
} else {
profileAndMessageHelper.insertUpdateMessages(timestamp, currentLocalState, applyGroupStateDiffResult.processedLogEntries, serverGuid)
}
@@ -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)
@@ -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;

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