Compare commits

...

217 Commits

Author SHA1 Message Date
jeffrey-signal
d5f85c0661 Bump version to 8.8.0 2026-04-15 20:18:12 -04:00
jeffrey-signal
91458f2702 Update baseline profile. 2026-04-15 20:18:12 -04:00
jeffrey-signal
6650ffc2c6 Update translations and other static files. 2026-04-15 20:17:59 -04:00
Cody Henthorne
ab0102a372 Do not force-apply P2P group changes unless the change adds or removes us.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:14 -04:00
Cody Henthorne
a797bbf850 Improve web socket behaviors around keep alive and shutdown. 2026-04-15 14:45:14 -04:00
Cody Henthorne
3804890265 Stop putting e164s into SignalProtocolAddress. 2026-04-15 14:45:14 -04:00
Greyson Parrelli
fcdbf93626 Improve regV5 restore flows. 2026-04-15 14:45:14 -04:00
Michelle Tang
f1b61f8f7e Add test dispatcher to phone number tests. 2026-04-15 14:45:14 -04:00
Michelle Tang
ce582249ec Ask for permissions on the same screen. 2026-04-15 14:45:13 -04:00
Alex Hart
b21a72153a Implement proper text-entry component for large screen media send flow. 2026-04-15 14:45:13 -04:00
jeffrey-signal
2a8bd20bb0 Fix sender name/label clipping in recycled conversation items.
Resolves signalapp/Signal-Android#14646
2026-04-15 14:45:13 -04:00
jeffrey-signal
c30e3cc1b7 Disable group member labels while in message request state. 2026-04-15 14:45:13 -04:00
Greyson Parrelli
5fedd81921 Convert IndividualSendJob to kotlin. 2026-04-15 14:45:12 -04:00
jeffrey-signal
24069dc42e Fix self avatar in reaction bottom sheet. 2026-04-15 14:45:12 -04:00
Cody Henthorne
ff15c8417a Clear upload spec when resume location is invalid in archive upload.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:12 -04:00
Greyson Parrelli
cbf770d3ea Convert PushSendJob to kotlin. 2026-04-15 14:45:12 -04:00
Greyson Parrelli
676ab1ab6f Merge SendJob into PushSendJob. 2026-04-15 14:45:12 -04:00
Greyson Parrelli
9cc47942f2 Add process check to VoiceNotePlaybackService MediaSession access. 2026-04-15 14:45:12 -04:00
Greyson Parrelli
45e6e06c01 Improve error handling for empty prekey bundles. 2026-04-15 14:45:11 -04:00
Michelle Tang
d2243707b5 Update permissions UI. 2026-04-15 14:45:11 -04:00
Jesse Weinstein
48cd1c1da0 Convert Windows line endings to Unix format.
Closes signalapp/Signal-Android#14632
2026-04-15 14:45:11 -04:00
thomasboom
330a5aece2 Show "None" for media auto-download when all options are disabled.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>

Closes signalapp/Signal-Android#14678
2026-04-15 14:45:11 -04:00
Cody Henthorne
8c4f614d17 Allow reply action on notifications for messages with media attachments. 2026-04-15 14:45:11 -04:00
Leo Heitmann Ruiz
f40bcb73fa Fix duplicate word in bug report template.
Closes signalapp/Signal-Android#14695
2026-04-15 14:45:10 -04:00
Jesse Weinstein
905a6f1a6b Fix typo in comment referencing ChatTypeSearchKey.
Closes signalapp/Signal-Android#14684
2026-04-15 14:45:10 -04:00
Jesse Weinstein
8f78471849 Remove the annimon.stream library.
Resolves #14719
2026-04-15 14:45:10 -04:00
Jesse Weinstein
82df20190d Remove all remaining usages of annimon.stream. 2026-04-15 14:45:10 -04:00
Greyson Parrelli
7f6e96a522 Check DownloadManager status to properly detect errors. 2026-04-15 14:45:09 -04:00
Greyson Parrelli
eded335766 Fix potential crash when saving to fallback attachment folder.
Fixes #14720
2026-04-15 14:45:09 -04:00
Michelle Tang
7e4736969c Update country selection. 2026-04-15 14:45:09 -04:00
Greyson Parrelli
78940ffc17 Switch the labs plaintext export to share a single zip. 2026-04-15 14:45:09 -04:00
Jesse Weinstein
086883e565 Convert all the toList calls to collect(Collectors.toList)
Resolves #14718
2026-04-15 14:45:06 -04:00
Michelle Tang
e9cdf0368e Update phone number UI. 2026-04-15 14:43:45 -04:00
Greyson Parrelli
7be273f461 Do not interrupt actively playing voice notes when locking. 2026-04-15 14:43:45 -04:00
Jesse Weinstein
e6cbb0073c Remove more usages of annimon.stream.
Resolves #14717
2026-04-15 14:43:41 -04:00
Cody Henthorne
469421fcf3 Fix message request accepted to recipient from previous backup imports. 2026-04-15 14:43:12 -04:00
Greyson Parrelli
6d6d277277 Add share and forward entries to all media context menu. 2026-04-15 14:43:12 -04:00
jeffrey-signal
8a5faba985 Rename DragAndDrop -> DragToReorder to differentiate it from Android's drag-and-drop framework. 2026-04-15 14:43:12 -04:00
Cody Henthorne
7aadc208e1 Promptly remove terminated groups from shortcuts. 2026-04-15 14:43:11 -04:00
Greyson Parrelli
3c68e29679 Update image pasting to use ViewCompat.setOnReceiveContentListener. 2026-04-15 14:43:11 -04:00
Cody Henthorne
4756b8d70b Update conversation header and message request UI. 2026-04-15 14:43:11 -04:00
Alex Hart
c2d927029a Add new ImageEditor compose component and wire in crop and drawing tools. 2026-04-13 16:25:00 -04:00
jeffrey-signal
629b96dd20 Fix wallpaper ANR regression while maintaining correct incoming message bubble colors. 2026-04-13 16:25:00 -04:00
Greyson Parrelli
01705459cf Include registered state in support email. 2026-04-13 16:25:00 -04:00
Greyson Parrelli
c449f72786 Allow internal shares as long as they originate from our process. 2026-04-13 16:25:00 -04:00
Alex Hart
773d6c36dc Add large-screen media send toolbars for image editing. 2026-04-13 16:25:00 -04:00
andrew-signal
b4bfb67a44 Bump to libsignal v0.91.2 2026-04-13 16:25:00 -04:00
Michelle Tang
3165c854df Remove unused strings. 2026-04-13 16:25:00 -04:00
Cody Henthorne
f5cb1b0efa Restrict telecom usage to API 37. 2026-04-13 16:25:00 -04:00
Greyson Parrelli
179908fba6 Update registration error strings for SMS send failures. 2026-04-13 16:25:00 -04:00
Greyson Parrelli
d6ec4bfbd3 Do not show getting started after local restore. 2026-04-13 16:25:00 -04:00
Greyson Parrelli
237ac9f94a Use original message timestamp for saved attachment MediaStore metadata.
Fixes #14584
2026-04-13 16:25:00 -04:00
Greyson Parrelli
66f69854cf Expand whitespace character detection in StringUtil.isVisuallyEmpty.
Fixes #14470
2026-04-13 16:25:00 -04:00
Greyson Parrelli
8f47592fc0 Fix reconciliation error for thumbnails for quotes. 2026-04-13 16:25:00 -04:00
Greyson Parrelli
3ea7bf77e0 Add release note validation check. 2026-04-13 16:25:00 -04:00
Greyson Parrelli
2b67b1c44f Remove legacy ClassicOpenHelper. 2026-04-13 16:25:00 -04:00
Jesse Weinstein
ebccc6db30 Remove a lot of dead code.
Resolves #14672
2026-04-13 16:25:00 -04:00
Greyson Parrelli
98d9b12438 Add null check in UriUtil. 2026-04-13 16:22:10 -04:00
Greyson Parrelli
5db8463c70 Improve content proxy domain matching. 2026-04-13 16:22:10 -04:00
Greyson Parrelli
813252989b Notify CallRequestController after cancel. 2026-04-13 16:22:09 -04:00
Greyson Parrelli
0319adbce4 Add null check to orientation unboxing. 2026-04-13 16:22:09 -04:00
Greyson Parrelli
de584ccb7d Use message digest for faster comparison. 2026-04-13 16:22:09 -04:00
Greyson Parrelli
bd89c7fc39 Add null check to pin message import. 2026-04-13 16:22:09 -04:00
Greyson Parrelli
bef4bb40ca Do not swallow IOException during backup creation. 2026-04-13 16:22:08 -04:00
Jesse Weinstein
b57d922cdf Remove use of annimon.stream in several places.
Resolves #14705
2026-04-13 16:22:08 -04:00
Greyson Parrelli
8c1cc03c6f Use non-deprecated libsignal network constructor. 2026-04-13 16:22:08 -04:00
jeffrey-signal
f0109f3e6b Improve drag-to-reorder auto scroll behavior when dragging an item up the list. 2026-04-13 16:22:08 -04:00
Greyson Parrelli
ed89f3a78e Don't connect to the websocket if we know we're unregistered. 2026-04-13 16:22:07 -04:00
Alex Hart
faa6a1d3f0 Welcome screen polish. 2026-04-13 16:22:07 -04:00
Greyson Parrelli
969635d942 Add redirect validation for link previews. 2026-04-13 16:22:07 -04:00
Greyson Parrelli
7665ae1464 Verify payment address identity key against local identity store. 2026-04-13 16:22:07 -04:00
Greyson Parrelli
9c18e3698e Apply CDN restrictions to quote attachments. 2026-04-13 16:22:07 -04:00
Greyson Parrelli
df406633ff Use existing okhttp client + package checks for web apk. 2026-04-13 16:22:06 -04:00
Greyson Parrelli
d121f9402b Improve quote validation. 2026-04-13 16:22:06 -04:00
Greyson Parrelli
5310c19b99 Update isValidExternalUri. 2026-04-13 16:22:06 -04:00
Greyson Parrelli
cd92feb2b7 Write profile avatars to temp file before renaming to final location. 2026-04-13 16:22:06 -04:00
Greyson Parrelli
3b603f08ed Add defensive size check to stream read. 2026-04-13 16:22:06 -04:00
Greyson Parrelli
281f062b29 Remove the DeprecatedPersistentBlobProvider. 2026-04-13 16:22:05 -04:00
jeffrey-signal
b054a7eb76 Bump version to 8.7.3 2026-04-13 16:15:41 -04:00
jeffrey-signal
33b9c88ecd Update baseline profile. 2026-04-13 15:40:05 -04:00
jeffrey-signal
253d36ae13 Update translations and other static files. 2026-04-13 15:32:54 -04:00
Michelle Tang
8306f8ec5b Improve collapsed set selection. 2026-04-13 14:05:20 -04:00
Michelle Tang
69b6d7ef9a Fix missing gallery photos.
Resolves signalapp/Signal-Android#14709
2026-04-13 14:05:00 -04:00
Greyson Parrelli
aeeba3d2df Fix NPE when there's no retryafter duration. 2026-04-12 14:20:32 -04:00
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
954 changed files with 53941 additions and 38656 deletions

View File

@@ -17,7 +17,7 @@ body:
label: "Guidelines"
description: "Search issues here: https://github.com/signalapp/Signal-Android/issues/?q=is%3Aissue+"
options:
- label: I have searched searched open and closed issues for duplicates
- label: I have searched open and closed issues for duplicates
required: true
- label: I am submitting a bug report for existing functionality that does not work as intended
required: true

23
.github/stale.yml vendored
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
.github/workflows/stale.yml vendored Normal file
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

View File

@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1672
val canonicalVersionName = "8.5.1"
val canonicalVersionCode = 1680
val canonicalVersionName = "8.8.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -692,7 +692,6 @@ dependencies {
implementation(libs.android.tooltips) {
exclude(group = "com.android.support", module = "appcompat-v7")
}
implementation(libs.stream)
implementation(libs.lottie)
implementation(libs.lottie.compose)
implementation(libs.signal.android.database.sqlcipher)

View File

@@ -26454,61 +26454,6 @@
column="7"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor mmsCursor = db.query(&quot;mms&quot;, new String[] {&quot;_id&quot;},"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="298"
column="38"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor partCursor = db.query(&quot;part&quot;, new String[] {&quot;_id&quot;, &quot;ct&quot;, &quot;_data&quot;, &quot;encrypted&quot;},"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="310"
column="32"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor threadCursor = db.query(&quot;thread&quot;, new String[] {&quot;_id&quot;}, null, null, null, null, null);"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="708"
column="32"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor cursor = db.rawQuery(&quot;SELECT DISTINCT date AS date_received, status, &quot; +"
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="713"
column="28"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" cursor = db.query(&quot;mms&quot;, new String[] {&quot;_id&quot;, &quot;network_failures&quot;}, &quot;network_failures IS NOT NULL&quot;, null, null, null, null);"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="1037"
column="19"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is always >= 21"

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.database
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockkStatic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -35,8 +34,6 @@ class ThreadTableTest_active {
fun setUp() {
mockkStatic(RemoteConfig::class)
every { RemoteConfig.showChatFolders } returns true
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.database
import io.mockk.every
import io.mockk.mockkStatic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -30,8 +29,6 @@ class ThreadTableTest_pinned {
fun setUp() {
mockkStatic(RemoteConfig::class)
every { RemoteConfig.showChatFolders } returns true
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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 {

View File

@@ -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()
)
}

View File

@@ -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())

View File

@@ -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()
}
}

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"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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() {

View File

@@ -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);
}
}

View File

@@ -87,6 +87,7 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -340,7 +341,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this,
currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
.collect(Collectors.toSet()),
selectionLimit,
isMulti,
new ContactSearchAdapter.DisplayOptions(
@@ -365,9 +366,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
if (onRefreshListener != null) {
onRefreshListener.onRefresh();
}
contactSearchMediator.refresh();
}
@Override
@@ -469,7 +468,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(java.util.stream.Collectors.toList());
.collect(Collectors.toList());
}
public int getSelectedContactsCount() {
@@ -664,7 +663,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
.filter(r -> !contactSearchMediator.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.map(SelectedContact::forRecipientId)
.collect(java.util.stream.Collectors.toSet());
.collect(Collectors.toSet());
if (toMarkSelected.isEmpty()) {
return;

View File

@@ -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)

View File

@@ -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() {

View File

@@ -19,7 +19,7 @@ package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Bundle;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.SelectedContact;
@@ -58,7 +58,7 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
protected final void onFinishedSelection() {
Intent resultIntent = getIntent();
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId()).toList();
List<RecipientId> recipients = selectedContacts.stream().map(sc -> sc.getOrCreateRecipientId()).collect(Collectors.toList());
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.apkupdate
import android.app.DownloadManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -53,6 +54,13 @@ object ApkUpdateInstaller {
return
}
if (!isDownloadSuccessful(context, downloadId)) {
Log.w(TAG, "DownloadId matches, but the download was not successful. The download may have failed due to a network issue. Clearing state and re-checking for updates.")
SignalStore.apkUpdate.clearDownloadAttributes()
AppDependencies.jobManager.add(ApkUpdateJob())
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
@@ -91,6 +99,8 @@ object ApkUpdateInstaller {
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(context.packageName)
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
// This lets us skip the system-generated notification.
if (Build.VERSION.SDK_INT >= 31) {
@@ -134,6 +144,35 @@ object ApkUpdateInstaller {
}
}
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = context.getDownloadManager().query(query)
return cursor.use { cursor ->
if (cursor.moveToFirst()) {
val status = cursor
.getColumnIndex(DownloadManager.COLUMN_STATUS)
.takeUnless { it == -1 }
?.let { cursor.getInt(it) } ?: DownloadManager.STATUS_FAILED
if (status == DownloadManager.STATUS_SUCCESSFUL) {
return@use true
}
val reason = cursor
.getColumnIndex(DownloadManager.COLUMN_REASON)
.takeUnless { it == -1 }
?.let { cursor.getInt(it) }
Log.w(TAG, "Download not successful. Status: $status, Reason: $reason")
false
} else {
Log.w(TAG, "Download ID $downloadId not found in DownloadManager.")
false
}
}
}
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->

View File

@@ -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)

View File

@@ -145,6 +145,11 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
if (cdn == Cdn.S3) {
return Optional.empty()
}
return Optional.of(
PointerAttachment(
quote = true,
@@ -153,7 +158,7 @@ class PointerAttachment : Attachment {
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
fileName = quotedAttachment.fileName,
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
cdn = cdn,
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
key = thumbnail?.asPointer()?.key?.let { Base64.encodeWithPadding(it) },
iv = null,

View File

@@ -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 {

View File

@@ -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

View File

@@ -11,8 +11,6 @@ import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
@@ -71,6 +69,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import okio.ByteString;

View File

@@ -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
}
}

View File

@@ -175,6 +175,10 @@ object ExportSkips {
return log(sentTimestamp, "Invalid e164 in sessions switchover event. Exporting an empty event.")
}
fun donationRequestNotInReleaseNotesChat(sentTimestamp: Long): String {
return log(sentTimestamp, "Donation request not in Release Notes chat.")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}
@@ -194,6 +198,10 @@ object ExportOddities {
return log(sentTimestamp, "Revisions for this message contained items of a different type than the parent item. Ignoring mismatched revisions.")
}
fun mismatchedRevisionAuthor(sentTimestamp: Long): String {
return log(sentTimestamp, "Revisions for this message contained items with a different author than the parent item. Ignoring mismatched revisions.")
}
fun outgoingMessageWasSentButTimerNotStarted(sentTimestamp: Long): String {
return log(sentTimestamp, "Outgoing expiring message was sent, but the timer wasn't started. Setting expireStartDate to dateReceived.")
}

View File

@@ -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

View File

@@ -69,6 +69,7 @@ data class ArchiveRestoreProgressState(
WAITING_FOR_INTERNET,
WAITING_FOR_WIFI,
NOT_ENOUGH_DISK_SPACE,
FINISHED
FINISHED,
LOCAL_RESTORE_DIRECTORY_UNAVAILABLE
}
}

View File

@@ -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
)

View File

@@ -241,6 +241,10 @@ class ChatItemArchiveExporter(
}
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
if (exportState.threadIdToRecipientId[builder.chatId] != exportState.releaseNoteRecipientId) {
Log.w(TAG, ExportSkips.donationRequestNotInReleaseNotesChat(builder.dateSent))
continue
}
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
transformTimer.emit("simple-update")
}
@@ -1741,19 +1745,24 @@ private fun ChatUpdateMessage.canOnlyBeAuthoredBySelf(): Boolean {
}
private fun List<ChatItem>.repairRevisions(current: ChatItem.Builder): List<ChatItem> {
val authorFiltered = this.filter { it.authorId == current.authorId }
if (authorFiltered.size != this.size) {
Log.w(TAG, ExportOddities.mismatchedRevisionAuthor(current.dateSent))
}
return if (current.standardMessage != null) {
val filtered = this
val filtered = authorFiltered
.filter { it.standardMessage != null }
.map { it.withDowngradeVoiceNotes() }
if (this.size != filtered.size) {
if (authorFiltered.size != filtered.size) {
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
}
filtered
} else if (current.directStoryReplyMessage != null) {
val filtered = this.filter { it.directStoryReplyMessage != null }
if (this.size != filtered.size) {
val filtered = authorFiltered.filter { it.directStoryReplyMessage != null }
if (authorFiltered.size != filtered.size) {
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
}
filtered

View File

@@ -362,12 +362,13 @@ class ChatItemArchiveImporter(
} else if (pinMessage != null) {
followUps += { pinUpdateMessageId ->
val targetAuthorId = importState.remoteToLocalRecipientId[pinMessage.authorId]
if (targetAuthorId != null) {
val targetAuthorAci = targetAuthorId?.let { recipients.getRecord(it).aci }
if (targetAuthorId != null && targetAuthorAci != null) {
val pinnedMessageId = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp, targetAuthorId)?.id ?: -1
val messageExtras = MessageExtras(
pinnedMessage = PinnedMessage(
pinnedMessageId = pinnedMessageId,
targetAuthorAci = recipients.getRecord(targetAuthorId).aci!!.toByteString(),
targetAuthorAci = targetAuthorAci.toByteString(),
targetTimestamp = pinMessage.targetSentTimestamp
)
)
@@ -383,6 +384,8 @@ class ChatItemArchiveImporter(
.where("${MessageTable.ID} = ?", pinnedMessageId)
.run()
}
} else {
Log.w(TAG, "Pin message target author not found or has no ACI, skipping pin message extras.")
}
}
}
@@ -472,6 +475,7 @@ class ChatItemArchiveImporter(
val ids = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, listOf(longTextAttachment), emptyList())
ids.values.firstOrNull()?.let { attachmentId ->
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
}
}
}
@@ -511,6 +515,7 @@ class ChatItemArchiveImporter(
if (longTextAttachment != null) {
attachmentMap[longTextAttachment]?.let { attachmentId ->
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
}
}
@@ -713,7 +718,7 @@ class ChatItemArchiveImporter(
when {
itemStandardMessage != null -> contentValues.addStandardMessage(itemStandardMessage)
itemRemoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId)
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId, chatRecipientId)
itemPaymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
itemGiftBadge != null -> contentValues.addGiftBadge(itemGiftBadge)
itemViewOnceMessage != null -> contentValues.addViewOnce(itemViewOnceMessage)
@@ -861,7 +866,7 @@ class ChatItemArchiveImporter(
}
}
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId) {
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId, chatRecipientId: RecipientId) {
var typeFlags: Long = 0
val simpleUpdate = updateMessage.simpleUpdate
val expirationTimerChange = updateMessage.expirationTimerChange
@@ -902,6 +907,11 @@ class ChatItemArchiveImporter(
put(MessageTable.FROM_RECIPIENT_ID, toRecipientId.serialize())
put(MessageTable.TO_RECIPIENT_ID, fromRecipientId.serialize())
}
// directionless 1:1 message requests expect to recipient to be the other recipient not self
if (simpleUpdate.type == SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED) {
put(MessageTable.TO_RECIPIENT_ID, chatRecipientId.serialize())
}
}
expirationTimerChange != null -> {
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT

View File

@@ -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)
}
}
}

View File

@@ -205,7 +205,7 @@ private fun FeatureBullet(text: String) {
modifier = Modifier.padding(vertical = 2.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
imageVector = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_check_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.core.util.next
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.next
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import kotlin.time.Duration.Companion.seconds

View File

@@ -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
}
)
}

View File

@@ -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)
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

@@ -72,6 +72,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -413,7 +414,7 @@ private fun IssueChip(
leadingIcon = {
Icon(
imageVector = if (isSelected) {
ImageVector.vectorResource(R.drawable.symbol_check_24)
ImageVector.vectorResource(CoreUiR.drawable.symbol_check_24)
} else {
ImageVector.vectorResource(issue.category.icon)
},

View File

@@ -1,69 +0,0 @@
package org.thoughtcrime.securesms.color;
import android.content.Context;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class MaterialColors {
public static final MaterialColorList CONVERSATION_PALETTE = new MaterialColorList(new ArrayList<>(Arrays.asList(
MaterialColor.PLUM,
MaterialColor.CRIMSON,
MaterialColor.VERMILLION,
MaterialColor.VIOLET,
MaterialColor.INDIGO,
MaterialColor.TAUPE,
MaterialColor.ULTRAMARINE,
MaterialColor.BLUE,
MaterialColor.TEAL,
MaterialColor.FOREST,
MaterialColor.WINTERGREEN,
MaterialColor.BURLAP,
MaterialColor.STEEL
)));
public static class MaterialColorList {
private final List<MaterialColor> colors;
private MaterialColorList(List<MaterialColor> colors) {
this.colors = colors;
}
public MaterialColor get(int index) {
return colors.get(index);
}
public int size() {
return colors.size();
}
public @Nullable MaterialColor getByColor(Context context, int colorValue) {
for (MaterialColor color : colors) {
if (color.represents(context, colorValue)) {
return color;
}
}
return null;
}
public @ColorInt int[] asConversationColorArray(@NonNull Context context) {
int[] results = new int[colors.size()];
int index = 0;
for (MaterialColor color : colors) {
results[index++] = color.toConversationColor(context);
}
return results;
}
}
}

View File

@@ -4,7 +4,6 @@ import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.os.Build;
import android.os.Bundle;
import android.text.Annotation;
import android.text.Editable;
import android.text.Selection;
@@ -26,9 +25,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
@@ -69,7 +65,6 @@ public class ComposeText extends EmojiEditText {
private MentionValidatorWatcher mentionValidatorWatcher;
private MessageSendType lastMessageSendType;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
@Nullable private StylingChangedListener stylingChangedListener;
@@ -247,20 +242,7 @@ public class ComposeText extends EmojiEditText {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
if (mediaListener == null) {
return inputConnection;
}
if (inputConnection == null) {
return null;
}
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" });
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
}
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
return inputConnection;
}
public boolean hasMentions() {
@@ -577,38 +559,6 @@ public class ComposeText extends EmojiEditText {
return true;
}
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
private static final String TAG = Log.tag(CommitContentListener.class);
private final InputPanel.MediaListener mediaListener;
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
}
@Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
try {
inputContentInfo.requestPermission();
} catch (Exception e) {
Log.w(TAG, e);
return false;
}
}
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
inputContentInfo.getDescription().getMimeType(0));
return true;
}
return false;
}
}
private static class QueryStart {
public int index;
public boolean isMentionQuery;

View File

@@ -5,7 +5,6 @@ import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.hardware.Camera;
import android.net.Uri;
import android.text.SpannableString;
import android.text.format.DateUtils;
import android.util.AttributeSet;
@@ -208,10 +207,6 @@ public class InputPanel extends ConstraintLayout
}
}
public void setMediaListener(@NonNull MediaListener listener) {
composeText.setMediaListener(listener);
}
public void setQuote(@NonNull RequestManager requestManager,
long id,
@NonNull Recipient author,
@@ -954,8 +949,4 @@ public class InputPanel extends ConstraintLayout
};
}
}
public interface MediaListener {
void onMediaSelected(@NonNull Uri uri, String contentType);
}
}

View File

@@ -289,8 +289,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
QuoteViewColorTheme colorTheme = getColorTheme();
int foregroundColor = colorTheme.getForegroundColor(getContext());
authorView.setSender(name, foregroundColor);
authorView.setLabel(memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
authorView.bind(name, foregroundColor, memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
}
private boolean isStoryReply() {

View File

@@ -16,15 +16,14 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.annimon.stream.Stream;
import com.bumptech.glide.RequestManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
@@ -116,7 +115,7 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
this.locale = locale;
this.contact = contact;
Stream.of(activeRecipients.values()).forEach(recipient -> recipient.removeForeverObserver(this));
activeRecipients.values().stream().forEach(recipient -> recipient.removeForeverObserver(this));
this.activeRecipients.clear();
presentContact(contact);

View File

@@ -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);
}

View File

@@ -7,13 +7,12 @@ import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.Util;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.signal.core.util.Util;
import java.util.ArrayList;
import java.util.Collections;
@@ -140,7 +139,7 @@ public class TypingStatusRepository {
notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage));
Set<Long> activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
Set<Long> activeThreads = typistMap.keySet().stream().filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
threadsNotifier.postValue(activeThreads);
}

View File

@@ -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,

View File

@@ -9,7 +9,7 @@ import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
@@ -72,7 +72,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
@Override public List<Emoji> getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList();
return getEmoji().stream().map(Emoji::new).collect(Collectors.toList());
}
@Override public @Nullable Uri getSpriteUri() {

View File

@@ -8,13 +8,14 @@ import android.text.Spanned;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
/**
* This wraps an Android standard {@link Annotation} so it can leverage the built in
@@ -51,13 +52,12 @@ public final class MentionAnnotation {
public static @NonNull List<Mention> getMentionsFromAnnotations(@Nullable CharSequence text) {
if (text instanceof Spanned) {
Spanned spanned = (Spanned) text;
return Stream.of(getMentionAnnotations(spanned))
.map(annotation -> {
return getMentionAnnotations(spanned).stream()
.map(annotation -> {
int spanStart = spanned.getSpanStart(annotation);
int spanLength = spanned.getSpanEnd(annotation) - spanStart;
return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength);
})
.toList();
}).collect(Collectors.toList());
}
return Collections.emptyList();
}
@@ -68,7 +68,6 @@ public final class MentionAnnotation {
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
return Stream.of(spanned.getSpans(start, end, Annotation.class))
.filter(MentionAnnotation::isMentionAnnotation)
.toList();
.filter(MentionAnnotation::isMentionAnnotation).collect(Collectors.toList());
}
}

View File

@@ -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)

View File

@@ -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)
},

View File

@@ -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) {

View File

@@ -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
)

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -52,10 +52,10 @@ import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
import org.signal.core.ui.compose.list.ReorderListEvent
import org.signal.core.ui.compose.list.ReorderableItem
import org.signal.core.ui.compose.list.rememberReorderableListState
import org.signal.core.ui.compose.list.reorderableList
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -101,11 +101,11 @@ class ChatFoldersFragment : ComposeFragment() {
onDeleteDismissed = {
viewModel.showDeleteDialog(false)
},
onDragAndDropEvent = { event ->
onReorderListEvent = { event ->
when (event) {
is DragAndDropEvent.OnItemMove -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
is DragAndDropEvent.OnItemDrop -> viewModel.saveItemPositions()
is DragAndDropEvent.OnDragCancel -> {}
is ReorderListEvent.ItemMoved -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
is ReorderListEvent.ItemDropped -> viewModel.saveItemPositions()
is ReorderListEvent.DragCanceled -> {}
}
}
)
@@ -123,10 +123,10 @@ fun FoldersScreen(
onDeleteClicked: (ChatFolderRecord) -> Unit = {},
onDeleteConfirmed: () -> Unit = {},
onDeleteDismissed: () -> Unit = {},
onDragAndDropEvent: (DragAndDropEvent) -> Unit = {}
onReorderListEvent: (ReorderListEvent) -> Unit = {}
) {
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = onDragAndDropEvent)
val reorderableListState = rememberReorderableListState(listState, includeHeader = true, includeFooter = true, onEvent = onReorderListEvent)
LaunchedEffect(Unit) {
if (!SignalStore.uiHints.hasSeenChatFoldersEducationSheet) {
@@ -147,14 +147,14 @@ fun FoldersScreen(
}
LazyColumn(
modifier = Modifier.dragContainer(
dragDropState = dragDropState,
modifier = Modifier.reorderableList(
reorderableListState = reorderableListState,
dragHandleWidth = 56.dp
),
state = listState
) {
item {
DraggableItem(dragDropState, 0) {
ReorderableItem(reorderableListState, 0) {
Text(
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
style = MaterialTheme.typography.bodyMedium,
@@ -175,7 +175,7 @@ fun FoldersScreen(
}
itemsIndexed(state.folders) { index, folder ->
DraggableItem(dragDropState, 1 + index) { isDragging ->
ReorderableItem(reorderableListState, 1 + index) { isDragging ->
val elevation = if (isDragging) 1.dp else 0.dp
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
FolderRow(
@@ -193,7 +193,7 @@ fun FoldersScreen(
}
item {
DraggableItem(dragDropState, 1 + state.folders.size) {
ReorderableItem(reorderableListState, 1 + state.folders.size) {
if (state.suggestedFolders.isNotEmpty()) {
Dividers.Default()

View File

@@ -142,6 +142,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.mobileAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onMobileDataAutoDownloadSelectionChanged
)
}
@@ -152,6 +153,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.wifiAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onWifiDataAutoDownloadSelectionChanged
)
}
@@ -162,6 +164,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.roamingAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onRoamingDataAutoDownloadSelectionChanged
)
}

View File

@@ -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())
}
)

View File

@@ -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(

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
timestamp = insertedAt.inWholeMilliseconds,
error = null,
pendingVerification = true,
checkedVerification = data.waitForAuth!!.checkedVerification
checkedVerification = data.waitForAuth?.checkedVerification ?: false
)
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -215,7 +215,7 @@ class ConversationSettingsRepository(
@WorkerThread
fun isMessageRequestAccepted(recipient: Recipient): Boolean {
return RecipientUtil.isMessageRequestAccepted(context, recipient)
return RecipientUtil.isMessageRequestAccepted(recipient)
}
fun getMembershipCountDescription(liveGroup: LiveGroup): LiveData<String> {

View File

@@ -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

View File

@@ -1,16 +1,15 @@
package org.thoughtcrime.securesms.components.voice;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Process;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -23,14 +22,8 @@ import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaController;
import androidx.media3.session.MediaSession;
import androidx.media3.session.MediaSessionService;
import androidx.media3.session.SessionToken;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
@@ -45,8 +38,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
import org.thoughtcrime.securesms.mms.PartUriParser;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.KeyCachingService;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -66,7 +57,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
private MediaSession mediaSession;
private VoiceNotePlayer player;
private KeyClearedReceiver keyClearedReceiver;
private VoiceNotePlayerCallback voiceNotePlayerCallback;
private final DatabaseObserver.Observer attachmentDeletionObserver = this::onAttachmentDeleted;
@@ -88,8 +78,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
mediaSession = session;
}
keyClearedReceiver = new KeyClearedReceiver(this, session.getToken());
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
setListener(new MediaSessionServiceListener());
AppDependencies.getDatabaseObserver().registerAttachmentDeletedObserver(attachmentDeletionObserver);
@@ -121,11 +109,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
mediaSession = null;
}
KeyClearedReceiver receiver = keyClearedReceiver;
if (receiver != null) {
receiver.unregister();
}
clearListener();
super.onDestroy();
}
@@ -133,6 +116,10 @@ public class VoiceNotePlaybackService extends MediaSessionService {
@Nullable
@Override
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
if (Build.VERSION.SDK_INT >= 28 && controllerInfo.getUid() != Process.myUid()) {
Log.w(TAG, "Denying session to external caller: " + controllerInfo.getPackageName());
return null;
}
return mediaSession;
}
@@ -375,71 +362,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
}
}
/**
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
* This registers itself as a receiver on the [Context] as soon as it can.
*/
private static class KeyClearedReceiver extends BroadcastReceiver {
private static final String TAG = Log.tag(KeyClearedReceiver.class);
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
private final Context context;
private final ListenableFuture<MediaController> controllerFuture;
private MediaController controller;
private boolean registered;
private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) {
this.context = context;
Log.d(TAG, "Creating media controller…");
controllerFuture = new MediaController.Builder(context, token).buildAsync();
Futures.addCallback(controllerFuture, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable MediaController result) {
Log.d(TAG, "Successfully created media controller.");
controller = result;
register();
}
@Override
public void onFailure(@NonNull Throwable t) {
Log.w(TAG, "KeyClearedReceiver.onFailure", t);
}
}, ContextCompat.getMainExecutor(context));
}
void register() {
if (controller == null) {
Log.w(TAG, "Failed to register KeyClearedReceiver because MediaController was null.");
return;
}
if (!registered) {
ContextCompat.registerReceiver(context, this, KEY_CLEARED_FILTER, ContextCompat.RECEIVER_NOT_EXPORTED);
registered = true;
Log.d(TAG, "Successfully registered.");
}
}
void unregister() {
if (registered) {
context.unregisterReceiver(this);
registered = false;
}
MediaController.releaseFuture(controllerFuture);
}
@Override
public void onReceive(Context context, Intent intent) {
if (controller == null) {
Log.w(TAG, "Received broadcast but could not stop playback because MediaController was null.");
} else {
Log.i(TAG, "Received broadcast, stopping playback.");
controller.stop();
}
}
}
private static class MediaSessionServiceListener implements Listener {
@Override
public void onForegroundServiceStartNotAllowedException() {

View File

@@ -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 {

View File

@@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.components.voice
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.OptIn
@@ -94,6 +96,10 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
private var latestUri = Uri.EMPTY
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
if (Build.VERSION.SDK_INT >= 28 && controller.uid != Process.myUid()) {
Log.w(TAG, "Rejecting connection from external caller: ${controller.packageName}")
return MediaSession.ConnectionResult.reject()
}
return MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
}
@@ -207,6 +213,8 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
player.playWhenReady = true
} else {
Log.i(TAG, "Audio stream set to $newStreamType. Not playing when ready.")
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))

View File

@@ -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
}

View File

@@ -3,8 +3,7 @@ package org.thoughtcrime.securesms.components.webrtc;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.SetUtil;
import org.thoughtcrime.securesms.events.CallParticipant;
@@ -68,12 +67,12 @@ public final class CallParticipantListUpdate {
public static @NonNull CallParticipantListUpdate computeDeltaUpdate(@NonNull List<CallParticipant> oldList,
@NonNull List<CallParticipant> newList)
{
Set<CallParticipantListUpdate.Wrapper> oldParticipants = Stream.of(oldList)
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
Set<CallParticipantListUpdate.Wrapper> oldParticipants = oldList.stream()
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
.map(CallParticipantListUpdate::createWrapper)
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Wrapper> newParticipants = Stream.of(newList)
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
Set<CallParticipantListUpdate.Wrapper> newParticipants = newList.stream()
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
.map(CallParticipantListUpdate::createWrapper)
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Wrapper> added = SetUtil.difference(newParticipants, oldParticipants);

View File

@@ -4,7 +4,6 @@ import android.content.Context
import androidx.annotation.Discouraged
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.annimon.stream.OptionalLong
import kotlinx.collections.immutable.toImmutableList
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
@@ -19,6 +18,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import java.util.Optional
import java.util.concurrent.TimeUnit
/**
@@ -37,7 +37,7 @@ data class CallParticipantsState(
val isInPipMode: Boolean = false,
private val showVideoForOutgoing: Boolean = false,
val isViewingFocusedParticipant: Boolean = false,
val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
val remoteDevicesCount: Optional<Long> = Optional.empty(),
private val foldableState: FoldableState = FoldableState.flat(),
val isInOutgoingRingingMode: Boolean = false,
val recipient: Recipient = Recipient.UNKNOWN,
@@ -87,11 +87,11 @@ data class CallParticipantsState(
return listParticipants
}
val participantCount: OptionalLong
val participantCount: Optional<Long>
get() {
val includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED
return remoteDevicesCount.map { l: Long -> l + if (includeSelf) 1L else 0L }
.or { if (includeSelf) OptionalLong.of(1L) else OptionalLong.empty() }
.or { if (includeSelf) Optional.of(1L) else Optional.empty() }
}
fun getPreJoinGroupDescription(context: Context): String? {
@@ -358,7 +358,7 @@ data class CallParticipantsState(
@PluralsRes multipleParticipants: Int,
members: List<GroupMemberEntry.FullMember>
): String {
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked }
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked || it.member.isUnregistered }
return when (eligibleMembers.size) {
0 -> noParticipants?.let { context.getString(noParticipants) } ?: ""

View File

@@ -91,7 +91,7 @@ object CallInfoView {
inCallLobby = state.callState == WebRtcViewModel.State.CALL_PRE_JOIN,
ringGroup = state.ringGroup,
includeSelf = state.groupCallState === WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED || state.groupCallState === WebRtcViewModel.GroupCallState.IDLE,
participantCount = if (state.participantCount.isPresent) state.participantCount.asLong.toInt() else 0,
participantCount = if (state.participantCount.isPresent) state.participantCount.get().toInt() else 0,
remoteParticipants = state.allRemoteParticipants.sortedBy { it.callParticipantId.recipientId },
localParticipant = state.localParticipant,
groupMembers = state.groupMembers.filterNot { it.member.isSelf },

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
)
}
}

View File

@@ -1,76 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.compose
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.View
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.boundsInWindow
/**
* Helper class for screenshotting compose views.
*
* You need to call bind from the compose, passing in the
* LocalView.current view with bounds fetched from when the
* composable is globally positioned.
*
* See QrCodeBadge.kt for an example
*/
class ScreenshotController {
private var screenshotCallback: (() -> Bitmap?)? = null
fun bind(view: View, bounds: Rect?) {
if (bounds == null) {
screenshotCallback = null
return
}
screenshotCallback = {
val bitmap = Bitmap.createBitmap(
bounds.width.toInt(),
bounds.height.toInt(),
Bitmap.Config.ARGB_8888
)
if (Build.VERSION.SDK_INT >= 26) {
PixelCopy.request(
(view.context as Activity).window,
android.graphics.Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()),
bitmap,
{},
Handler(Looper.getMainLooper())
)
} else {
val canvas = Canvas(bitmap)
.apply {
translate(-bounds.left, -bounds.top)
}
view.draw(canvas)
}
bitmap
}
}
fun screenshot(): Bitmap? {
return screenshotCallback?.invoke()
}
}
fun LayoutCoordinates.getScreenshotBounds(): Rect {
return if (Build.VERSION.SDK_INT >= 26) {
this.boundsInWindow()
} else {
this.boundsInRoot()
}
}

View File

@@ -1,168 +0,0 @@
package org.thoughtcrime.securesms.contacts;
/*
* Copyright (C) 2006 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.database.AbstractCursor;
import android.database.CursorWindow;
import java.util.ArrayList;
/**
* A convenience class that presents a two-dimensional ArrayList
* as a Cursor.
*/
public class ArrayListCursor extends AbstractCursor {
private String[] mColumnNames;
private ArrayList<Object>[] mRows;
@SuppressWarnings({"unchecked"})
public ArrayListCursor(String[] columnNames, ArrayList<ArrayList> rows) {
int colCount = columnNames.length;
boolean foundID = false;
// Add an _id column if not in columnNames
for (int i = 0; i < colCount; ++i) {
if (columnNames[i].compareToIgnoreCase("_id") == 0) {
mColumnNames = columnNames;
foundID = true;
break;
}
}
if (!foundID) {
mColumnNames = new String[colCount + 1];
System.arraycopy(columnNames, 0, mColumnNames, 0, columnNames.length);
mColumnNames[colCount] = "_id";
}
int rowCount = rows.size();
mRows = new ArrayList[rowCount];
for (int i = 0; i < rowCount; ++i) {
mRows[i] = rows.get(i);
if (!foundID) {
mRows[i].add(i);
}
}
}
@Override
public void fillWindow(int position, CursorWindow window) {
if (position < 0 || position > getCount()) {
return;
}
window.acquireReference();
try {
int oldpos = mPos;
mPos = position - 1;
window.clear();
window.setStartPosition(position);
int columnNum = getColumnCount();
window.setNumColumns(columnNum);
while (moveToNext() && window.allocRow()) {
for (int i = 0; i < columnNum; i++) {
final Object data = mRows[mPos].get(i);
if (data != null) {
if (data instanceof byte[]) {
byte[] field = (byte[]) data;
if (!window.putBlob(field, mPos, i)) {
window.freeLastRow();
break;
}
} else {
String field = data.toString();
if (!window.putString(field, mPos, i)) {
window.freeLastRow();
break;
}
}
} else {
if (!window.putNull(mPos, i)) {
window.freeLastRow();
break;
}
}
}
}
mPos = oldpos;
} catch (IllegalStateException e){
// simply ignore it
} finally {
window.releaseReference();
}
}
@Override
public int getCount() {
return mRows.length;
}
public boolean deleteRow() {
return false;
}
@Override
public String[] getColumnNames() {
return mColumnNames;
}
@Override
public byte[] getBlob(int columnIndex) {
return (byte[]) mRows[mPos].get(columnIndex);
}
@Override
public String getString(int columnIndex) {
Object cell = mRows[mPos].get(columnIndex);
return (cell == null) ? null : cell.toString();
}
@Override
public short getShort(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.shortValue();
}
@Override
public int getInt(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.intValue();
}
@Override
public long getLong(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.longValue();
}
@Override
public float getFloat(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.floatValue();
}
@Override
public double getDouble(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.doubleValue();
}
@Override
public boolean isNull(int columnIndex) {
return mRows[mPos].get(columnIndex) == null;
}
}

View File

@@ -1,168 +0,0 @@
/**
* Copyright (C) 2011 Whisper Systems
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.Contacts;
import java.util.LinkedList;
import java.util.List;
/**
* This class was originally a layer of indirection between
* ContactAccessorNewApi and ContactAccessorOldApi, which corresponded
* to the API changes between 1.x and 2.x.
*
* Now that we no longer support 1.x, this class mostly serves as a place
* to encapsulate Contact-related logic. It's still a singleton, mostly
* just because that's how it's currently called from everywhere.
*
* @author Moxie Marlinspike
*/
public class ContactAccessor {
private static final ContactAccessor instance = new ContactAccessor();
public static ContactAccessor getInstance() {
return instance;
}
public ContactData getContactData(Context context, Uri uri) {
String displayName = getNameFromContact(context, uri);
long id = Long.parseLong(uri.getLastPathSegment());
ContactData contactData = new ContactData(id, displayName);
try (Cursor numberCursor = context.getContentResolver().query(Phone.CONTENT_URI,
null,
Phone.CONTACT_ID + " = ?",
new String[] { contactData.id + "" },
null))
{
while (numberCursor != null && numberCursor.moveToNext()) {
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
String label = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.LABEL));
String number = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.NUMBER));
String typeLabel = Phone.getTypeLabel(context.getResources(), type, label).toString();
contactData.numbers.add(new NumberData(typeLabel, number));
}
}
return contactData;
}
private String getNameFromContact(Context context, Uri uri) {
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(uri, new String[] { Contacts.DISPLAY_NAME }, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
return cursor.getString(0);
}
} finally {
if (cursor != null)
cursor.close();
}
return null;
}
public static class NumberData implements Parcelable {
public static final Parcelable.Creator<NumberData> CREATOR = new Parcelable.Creator<NumberData>() {
public NumberData createFromParcel(Parcel in) {
return new NumberData(in);
}
public NumberData[] newArray(int size) {
return new NumberData[size];
}
};
public final String number;
public final String type;
public NumberData(String type, String number) {
this.type = type;
this.number = number;
}
public NumberData(Parcel in) {
number = in.readString();
type = in.readString();
}
public int describeContents() {
return 0;
}
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(number);
dest.writeString(type);
}
}
public static class ContactData implements Parcelable {
public static final Parcelable.Creator<ContactData> CREATOR = new Parcelable.Creator<ContactData>() {
public ContactData createFromParcel(Parcel in) {
return new ContactData(in);
}
public ContactData[] newArray(int size) {
return new ContactData[size];
}
};
public final long id;
public final String name;
public final List<NumberData> numbers;
public ContactData(long id, String name) {
this.id = id;
this.name = name;
this.numbers = new LinkedList<NumberData>();
}
public ContactData(Parcel in) {
id = in.readLong();
name = in.readString();
numbers = new LinkedList<NumberData>();
in.readTypedList(numbers, NumberData.CREATOR);
}
public int describeContents() {
return 0;
}
public void writeToParcel(Parcel dest, int flags) {
dest.writeLong(id);
dest.writeString(name);
dest.writeTypedList(numbers);
}
}
}

View File

@@ -7,7 +7,6 @@ import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import com.annimon.stream.Stream;
import org.signal.contacts.SystemContactsRepository;
import org.signal.core.util.logging.Log;
@@ -70,11 +69,10 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
Log.w(TAG, e);
}
} else if (unknownSystemE164s.size() > 0) {
List<Recipient> recipients = Stream.of(unknownSystemE164s)
.filter(s -> s.startsWith("+"))
.map(s -> Recipient.external(s))
.filter(it -> it != null)
.toList();
List<Recipient> recipients = unknownSystemE164s.stream()
.filter(s -> s.startsWith("+"))
.map(s -> Recipient.external(s))
.filter(it -> it != null).collect(Collectors.toList());
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown E164s, which are now " + recipients.size() + " recipients. Only syncing these specific contacts.");

View File

@@ -1,35 +0,0 @@
/**
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.contacts;
/**
* Name and number tuple.
*
* @author Moxie Marlinspike
*
*/
public class NameAndNumber {
public String name;
public String number;
public NameAndNumber(String name, String number) {
this.name = name;
this.number = number;
}
public NameAndNumber() {}
}

View File

@@ -200,7 +200,7 @@ class ContactSearchConfiguration private constructor(
/**
* Chat types that are displayed when creating a chat folder.
*
* Key: [ContactSearchKey.ChatType]
* Key: [ContactSearchKey.ChatTypeSearchKey]
* Data: [ContactSearchData.ChatTypeRow]
* Model: [ContactSearchAdapter.ChatTypeModel]
*/

View File

@@ -14,7 +14,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import com.bumptech.glide.RequestManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
@@ -75,9 +75,9 @@ class ContactFieldAdapter extends RecyclerView.Adapter<ContactFieldAdapter.Conta
fields.add(new Field(avatar));
}
fields.addAll(Stream.of(phoneNumbers).map(phone -> new Field(context, phone, locale)).toList());
fields.addAll(Stream.of(emails).map(email -> new Field(context, email)).toList());
fields.addAll(Stream.of(postalAddresses).map(address -> new Field(context, address)).toList());
fields.addAll(phoneNumbers.stream().map(phone -> new Field(context, phone, locale)).collect(Collectors.toList()));
fields.addAll(emails.stream().map(email -> new Field(context, email)).collect(Collectors.toList()));
fields.addAll(postalAddresses.stream().map(address -> new Field(context, address)).collect(Collectors.toList()));
notifyDataSetChanged();
}

View File

@@ -8,7 +8,7 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.thoughtcrime.securesms.contactshare.Contact.Name;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
@@ -82,7 +82,7 @@ class ContactShareEditViewModel extends ViewModel {
}
private <E extends Selectable> List<E> trimSelectables(List<E> selectables) {
return Stream.of(selectables).filter(Selectable::isSelected).toList();
return selectables.stream().filter(Selectable::isSelected).collect(Collectors.toList());
}
@NonNull

View File

@@ -12,7 +12,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
@@ -98,7 +97,7 @@ public final class ContactUtil {
return null;
}
List<Phone> mobileNumbers = Stream.of(contact.getPhoneNumbers()).filter(number -> number.getType() == Phone.Type.MOBILE).toList();
List<Phone> mobileNumbers = contact.getPhoneNumbers().stream().filter(number -> number.getType() == Phone.Type.MOBILE).collect(Collectors.toList());
if (mobileNumbers.size() > 0) {
return mobileNumbers.get(0);
}

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