Compare commits

...

169 Commits

Author SHA1 Message Date
Greyson Parrelli
36b626941f Bump version to 8.1.4 2026-03-06 15:56:02 -05:00
Greyson Parrelli
0605cc0a9c Update delete column migration to use a single insert. 2026-03-06 15:55:33 -05:00
Greyson Parrelli
43e7d65af5 Bump version to 8.1.3 2026-03-04 13:41:59 -05:00
Greyson Parrelli
386d8bb312 Update translations and other static files. 2026-03-04 13:41:32 -05:00
Michelle Tang
3fbd72092c Use batch inserting migration instead. 2026-03-02 17:30:54 -05:00
Greyson Parrelli
4e5b15cd88 Never notify for quotes in muted 1:1 chats. 2026-03-02 13:58:02 -05:00
Greyson Parrelli
8b2aeba3bd Bump version to 8.1.2 2026-02-27 22:44:39 -05:00
Greyson Parrelli
1d2334b920 Update translations and other static files. 2026-02-27 22:44:11 -05:00
jeffrey-signal
38a234ae66 Fix crash after inviting group members. 2026-02-27 22:29:06 -05:00
jeffrey-signal
2c1226dc02 Fix groups v1 migration suggestions dialog crash. 2026-02-27 22:27:58 -05:00
Greyson Parrelli
1df8ef6464 Fix backup import issue when we dedude messages with edits. 2026-02-27 16:25:30 -05:00
Alex Hart
f8d40bf86d Revert "Don't show 'Payment Pending' during backup subscription keep-alive flows."
This reverts commit e87aa22d32.
2026-02-27 17:02:15 -04:00
Alex Hart
58ab03b4e3 Fix crash when enabling vanity camera before capturer initialization. 2026-02-27 16:35:39 -04:00
Michelle Tang
0bf54e6b45 Fix network crash when unpinning. 2026-02-27 15:19:48 -05:00
jeffrey-signal
8fca0c69ac Bump version to 8.1.1 2026-02-26 21:51:58 -05:00
jeffrey-signal
70eb4ca2a1 Update baseline profile. 2026-02-26 21:29:00 -05:00
jeffrey-signal
9d9e30725e Update translations and other static files. 2026-02-26 21:20:51 -05:00
jeffrey-signal
ff9585ec7d Show member labels on the admin sheet. 2026-02-26 20:00:36 -05:00
Greyson Parrelli
a418c2750a Fix mute icons theming. 2026-02-26 13:54:11 -05:00
jeffrey-signal
9581994050 Handle network and permissions errors when saving group member label. 2026-02-26 10:34:16 -05:00
jeffrey-signal
316d0e67c5 Enforce member label emoji and text constraints. 2026-02-26 08:32:32 -05:00
Cody Henthorne
503bf04ec5 Bump version to 8.1.0 2026-02-25 20:01:23 -05:00
Cody Henthorne
d6b76936dd Update baseline profile. 2026-02-25 19:55:25 -05:00
Cody Henthorne
c53d16717b Update translations and other static files. 2026-02-25 19:46:16 -05:00
jeffrey-signal
2c747daa50 Disable member label button for users without permission to edit. 2026-02-25 19:38:12 -05:00
jeffrey-signal
0b2d3edcce Add member labels education sheet. 2026-02-25 19:38:12 -05:00
jeffrey-signal
955bcde062 Rotate send member labels flag. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
a91aa72fb4 Guard against missing integrity check in CopyAttachmentToArchiveJob.
Add a check for hadIntegrityCheckPerformed() before attempting to copy
an attachment to the archive. If the attachment's download has failed
(transferState == FAILED), requireMediaName() would throw an
IllegalArgumentException because the integrity check was never
completed. The fix resets the archive transfer state to NONE and skips,
allowing a future successful download to re-trigger archiving.
2026-02-25 19:38:12 -05:00
Alex Hart
163ece75b2 Remove note about call links if there are no call links selected. 2026-02-25 19:38:12 -05:00
Alex Hart
a8fb5f2598 Prevent EmojiTextView measurement oscillation on size changes. 2026-02-25 19:38:12 -05:00
Alex Hart
3a62ad67e1 Fix out-of-sync audio selection.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Greyson Parrelli
48f4e1ddc6 Rotate the android.cameraXModelBlockList and android.cameraXMixedModelBlockList flags. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
c37bb96aab Only bind camera use cases that the device supports.
The new camera implementation always bound all four CameraX use cases
(preview, image capture, video capture, and image analysis) regardless
of device capabilities. On devices with LEGACY camera hardware level,
this causes image capture to fail with "Capture request failed with
reason ERROR" because the hardware cannot handle that many simultaneous
use cases.

This change makes video capture and QR scanning use case binding
conditional based on CameraXModePolicy, which already determines device
capabilities. Video capture is only bound when the device supports mixed
mode (image + video simultaneously). QR scanning analysis is only bound
when explicitly requested.
2026-02-25 19:38:12 -05:00
jeffrey-signal
a2057e20d2 Rename UiCallbacks interfaces to avoid redeclaration errors. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
577e05eb51 Make sure we transcode non-H264 video. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
65a30cf2a7 Mark attachment 404's as permanent failures. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
121f0c6134 Add custom mute until option. 2026-02-25 19:38:12 -05:00
jeffrey-signal
7d1897a9d2 Add ability to set group member label from conversation settings. 2026-02-25 19:38:12 -05:00
Alex Hart
415dbd1b61 Fix issue with joining video call from lock screen.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Alex Hart
cfc1c35203 Eliminate unnecessary utilization of SubcomposeLayout which was causing a calling crash.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Alex Hart
911d7f3be8 Fix crash occurring when user rapidly enters and leaves a call.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Cody Henthorne
c06944da13 Add receipt processing benchmark tests. 2026-02-25 19:38:12 -05:00
Alex Hart
b6dd4a3579 Fix formatting in EditMessageRevisionTest. 2026-02-25 09:49:49 -04:00
Greyson Parrelli
b057e145c5 Ensure usernames are unique regardless of casing. 2026-02-25 00:34:22 -05:00
Greyson Parrelli
772ad3b929 Show gallery button on camera screen when camera permission is denied. 2026-02-24 23:46:42 -05:00
Michelle Tang
46681868d3 Put new deleting UI behind remote config. 2026-02-24 18:21:51 -05:00
Michelle Tang
75795bd7d5 Update incoming delete message strings. 2026-02-24 18:10:16 -05:00
Greyson Parrelli
1908723fbe Prevent potential ISE in MediaPreviewV2Fragment. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
549992c08a Fix potention NPE on video recording failures. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
845704b9fe Map UNKNOWN group member role to DEFAULT during backup export. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
ba03ca5e0c Drop quotes with unexported authors during backup export. 2026-02-24 16:50:01 -05:00
Cody Henthorne
92a9f12b58 Fix notification not being dismissed for read edited message. 2026-02-24 16:50:01 -05:00
Cody Henthorne
3437ac63bb Fix group recipient being created without a group record. 2026-02-24 16:50:01 -05:00
jeffrey-signal
d798a35c38 Member labels padding, margin, and styling fixes. 2026-02-24 16:50:01 -05:00
Alex Hart
01b56995d9 Add distinctUntilChanged to speaker hint flow to prevent repeated popups.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Greyson Parrelli
3f190efb4e Validate profile keys before writing them to backup exports. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
bb6b149c2e Fix potential validation error with mentions. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
65b96fff16 Delete some dead testing code. 2026-02-24 16:50:01 -05:00
jeffrey-signal
0b8e8a7b2f Separate v1 and v2 colorizer implementations. 2026-02-24 16:50:01 -05:00
jeffrey-signal
a8a6fec19d Show preview on edit member label screen. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
a3fce4c149 Filter hidden recipients from contact-joined notifications. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
85265412da Skip trigger drop/recreate in deleteMessagesInThread when there are no messages to delete.
The deleteMessagesInThread method unconditionally drops and recreates FTS
and MSL triggers in every call, even when there are no messages matching
the delete criteria. Each trigger drop/create cycle changes the database
schema cookie, causing SQLITE_SCHEMA errors for concurrent reader
connections.
2026-02-24 16:50:01 -05:00
andrew-signal
e636a94de0 Fix bug where we constantly cycled network stack when on network with PAC proxy. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
08509f6693 Fix bug where video dimensions aren't always correct in chat view. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
d28fc98cfd Add ability to use volume buttons to capture image/video. 2026-02-24 16:50:01 -05:00
Michelle Tang
f584ef1d72 Add network constraint to admin delete job. 2026-02-24 16:50:01 -05:00
Alex Hart
67a6df57c8 Allow user to cancel in-flight keep-alive donation. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
fadbb0adc5 Enable change animations in the conversation list. 2026-02-24 16:50:01 -05:00
Michelle Tang
58774033b7 Prioritize regular delete first. 2026-02-24 16:50:01 -05:00
Cody Henthorne
66f0470960 Improve incoming group message processing. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
68137cb66f Add internal config to schedule a message after the weekend. 2026-02-24 16:50:01 -05:00
Alex Hart
4d6cacdb3d Fix call controls flickering when starting a video call.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Alex Hart
cf862af3ca Increase bank transfer minimum name limit.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Alex Hart
a8d106a292 Disable audio focus for video GIF playback in media send flow.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Michelle Tang
6155140de4 Default to allowing multiple votes. 2026-02-24 16:50:01 -05:00
Alex Hart
a4637248e8 Set megaphone snooze for backups to 2 weeks. 2026-02-24 16:50:01 -05:00
Alex Hart
8c4470a27e Add logline for full-screen intent support. 2026-02-24 16:50:01 -05:00
Michelle Tang
071fbfd916 Add support for admin delete. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
1968438ebb Improve video transcoding error logging. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
7b31383b88 Improve video encoder/decoder fallback logic. 2026-02-24 16:50:01 -05:00
Cody Henthorne
093a79045d Fix incorrect sender key state for mismatch/stale devices. 2026-02-24 16:50:01 -05:00
Cody Henthorne
e4928b0084 Fix long database transaction when syncing system contact information. 2026-02-24 16:50:01 -05:00
jeffrey-signal
03420cf501 Prevent autofill framework from treating message input as a credential field. 2026-02-24 16:50:01 -05:00
Cody Henthorne
541b4674a8 Add remote_backups cta action for release notes. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
6e108a03d1 Improve video transcode test error detection. 2026-02-24 16:50:01 -05:00
Alex Hart
c9dd332abd Pre-Registration Restoration from Local Unified Backup. 2026-02-24 16:50:01 -05:00
jeffrey-signal
7e605fb6de Fix member label emoji ignoring use system emoji preference. 2026-02-24 16:50:01 -05:00
andrew-signal
fa2b0aedb0 Bump to libsignal v0.87.4 2026-02-24 16:50:01 -05:00
andrew-signal
402f49edd9 Replace usages of old getEncryptedUsernameFromLinkServerId for libsignal's lookUpUsernameLink. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
caf2e555dd Fix more HDR transcoding errors. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
32dc36d937 Fix various transcoding issues on samsung devices. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
771d49bfa8 Add an instrumentation test for video transcoding. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
70dc78601a Exclude time from apk output filename. 2026-02-24 16:50:01 -05:00
Cody Henthorne
b4d781ddbb Reduce calls to sleep for WebSocket keep alives. 2026-02-24 16:50:01 -05:00
jeffrey-signal
9c29601b55 Consolidate about sheet state into a single object. 2026-02-24 16:50:01 -05:00
jeffrey-signal
28c37cb3ac Add ability to edit member label from the about you sheet. 2026-02-24 16:50:01 -05:00
DivyaKhunt07
bd121e47c8 Fix bubble desired height calculation. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
7428e1e2ea Improve UI for regV5 verification code submission. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
376cb926b0 Give a more readable in-app version name to the nightly. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
4ed0056d2a Preserve user zoom level when starting video recording.
Remove the unconditional zoom reset to 1x at the start of video
recording so that any pinch-to-zoom the user applied before recording
is maintained.
2026-02-24 16:50:00 -05:00
Cody Henthorne
177ef8a555 Bump version to 8.0.4 2026-02-24 16:24:04 -05:00
Cody Henthorne
7244a1f52f Update translations and other static files. 2026-02-24 16:23:32 -05:00
Alex Hart
8d311923c1 Fix possible crash when restoring fragments. 2026-02-24 17:00:47 -04:00
Cody Henthorne
9359d56880 Bump version to 8.0.3 2026-02-23 11:42:37 -05:00
Cody Henthorne
3214200188 Update baseline profile. 2026-02-23 11:35:58 -05:00
Cody Henthorne
841ab7f983 Update translations and other static files. 2026-02-23 11:06:18 -05:00
Alex Hart
53b3728432 Update handling for early nav. 2026-02-23 11:13:42 -04:00
Alex Hart
cf9f98efc9 Fix bad behavior when rotating device with message details open. 2026-02-23 10:54:21 -04:00
Alex Hart
b5c666a1f4 Bump version to 8.0.2 2026-02-19 13:18:41 -04:00
Alex Hart
b1954a509c Update baseline profile. 2026-02-19 13:16:06 -04:00
Alex Hart
c2c91cfe42 Update translations and other static files. 2026-02-19 12:51:41 -04:00
Greyson Parrelli
cccbec5744 Only set HDR transcoder flags for HDR content. 2026-02-19 10:58:55 -05:00
Alex Hart
4c89b20fad Fix possible captcha race. 2026-02-19 11:14:14 -04:00
Greyson Parrelli
2328fa3e88 Route video GIF attachments to the GENERIC_TRANSCODE queue. 2026-02-19 09:57:23 -05:00
Greyson Parrelli
e19d4624c1 Fix video transcoding crash caused by premature codec API calls.
Move getParameterDescriptor and setParameters calls to after
configure/start, since they require the codec to be in the
Executing state. Always set KEY_COLOR_TRANSFER_REQUEST in the
format before configure as the primary tone-mapping mechanism.
2026-02-19 09:57:06 -05:00
Alex Hart
345f58ed48 Bump version to 8.0.1 2026-02-18 16:26:31 -04:00
Alex Hart
4c14ce3937 Update translations and other static files. 2026-02-18 16:17:34 -04:00
Alex Hart
82684c0169 Bump version to 8.0.0 2026-02-18 16:03:34 -04:00
Alex Hart
2607328255 Update translations and other static files. 2026-02-18 15:52:21 -04:00
Michelle Tang
484ce3a1da Turn on binary service writes. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
85d5f62301 Fix potential archive sync issue. 2026-02-18 15:48:16 -04:00
jeffrey-signal
b0571f8184 Fix edit group member label placeholder text. 2026-02-18 15:48:16 -04:00
Cody Henthorne
b80dd28b40 Defensively prevent gif playback in background. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
e0cf0808cf PIP moves opposite to finger in RTL mode.
Co-authored-by: Alex Hart <alex@signal.org>
2026-02-18 15:48:16 -04:00
Greyson Parrelli
ffdd5b62ae Remove now-unused colorInfo parsing from video transcoding. 2026-02-18 15:48:16 -04:00
jeffrey-signal
3b5376ef8b Fix mixed-directional text input behavior in recipient search field. 2026-02-18 15:48:16 -04:00
jeffrey-signal
cd57fb0d76 Fix member label pill overflow caused by excessive combining marks. 2026-02-18 15:48:16 -04:00
adel-signal
6986acd6f4 Update RingRTC to 2.65.0
Co-authored-by: emir-signal <emir@signal.org>
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-02-18 15:48:16 -04:00
Greyson Parrelli
2bc571ffd3 Possible fix for some transcoding issues. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
a8dddf33f8 Media needs to be tapped on twice for it to load during call.
Fixes #14581
Fixes #14592
2026-02-18 15:48:16 -04:00
Greyson Parrelli
46582a685b Fix weird screen transition when accepting an incoming call.
Co-authored-by: Alex Hart <alex@signal.org>
2026-02-18 15:48:16 -04:00
Greyson Parrelli
ad381783f7 Gifs stop or lower volume of other media playing.
Fixes #14297

Co-authored-by: Alex Hart <alex@signal.org>
2026-02-18 15:48:16 -04:00
jeffrey-signal
b81c1eb65c Fix crash when selecting a group to add a recipient. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
2c4d3b3ee4 Fix deleted attachment still visible under 'Shared media'. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
d1400928ce Slide in country picker vertically in regV5. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
49abece92b Includes quotes of your messages in 'notify for mentions' setting.
Fixes #14595
2026-02-18 15:48:16 -04:00
Greyson Parrelli
b48b1f031e Fix gradle format task. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
9cefe0bc04 Allow importing a backup as part of quickstart. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
ee73b0e229 Allow reading quickstart credentials from disk. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
ab0ce58812 Fix find-by-username text field using wrong background color.
The TextField was not explicitly setting container colors, causing it
to fall back to Material 3 defaults instead of the app's surfaceVariant
color. Added focusedContainerColor, unfocusedContainerColor, and
disabledContainerColor to match the pattern used throughout the rest
of the codebase.
2026-02-18 15:48:16 -04:00
Greyson Parrelli
333a206d36 Fix issue where emoji search bar got weird after tab switching. 2026-02-18 15:48:16 -04:00
Michelle Tang
86bb7666ea Fix unpinning all in note to self. 2026-02-18 15:48:16 -04:00
Cody Henthorne
58b5ebf39d Allow SendDeliveryReceiptJob to run during benchmarking tests. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
47947b85c7 Fix note to self media delete options. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
6910ba6d2e Ignore storageId in Recipient.hasSameContent().
Was potentially causing unnecessary UI churn.
2026-02-18 15:48:16 -04:00
Cody Henthorne
08254edae6 Add incoming group message benchmark tests. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
e67307a961 Add quickstart variant that launches with predefined credentials. 2026-02-18 15:48:16 -04:00
Cody Henthorne
9922621945 Add incoming individual message benchmark tests. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
c7476a2a07 Mute in lobby if accepting large group call. 2026-02-18 15:48:16 -04:00
Alex Hart
ac59528f5c Enable call vanity when joining a video call. 2026-02-18 15:48:16 -04:00
Alex Hart
97c9728c65 Resolve crash in blur container due to multiple measures. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
80d1694e6e Ensure data is updated when refreshing contacts during a search.
Fixes #14112
2026-02-18 15:48:16 -04:00
Greyson Parrelli
28c6e31c7d Removing a recipient does not immediately remove them from the contacts list. 2026-02-18 15:48:16 -04:00
Cody Henthorne
8836b2a570 Fix media send delayed after backgrounding app. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
786c2b888b Remove beta labeling from Signal Secure Backups. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
c91275c5da Add the country code picker to regV5. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
7b362460e7 Spruce up the welcome and permission screens in regV5. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
a1862c3420 Sticker from Google emoji keyboard renders as image attachment when forwarded. 2026-02-18 15:48:16 -04:00
jeffrey-signal
44ea9ccc59 Fix unique constraint violation when importing groups with duplicate recipient IDs. 2026-02-18 15:48:16 -04:00
andrew-signal
4c9cdf3b8f Refactor RefreshOwnProfileJob to Kotlin. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
4a6d4f197d Add 429 handling to various archive calls. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
ae04749336 Add better logging for ovesized messages. 2026-02-18 15:48:16 -04:00
Cody Henthorne
caa743aba2 Fix foreground service start in background crash by starting service sooner for incoming group calls. 2026-02-18 15:48:16 -04:00
Alex Hart
a4469a4285 Fix issue with initial audio output setting.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-18 15:48:08 -04:00
Alex Hart
2771b31aab Clear stale user-selected audio device when it disconnects.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-18 15:48:06 -04:00
Greyson Parrelli
5c418a4260 Add RRP support to regV5. 2026-02-18 15:48:06 -04:00
Alex Hart
17dbdf3b74 Bump version to 7.74.3 2026-02-18 11:38:55 -04:00
Alex Hart
8315ae47c4 Update translations and other static files. 2026-02-18 11:31:14 -04:00
Michelle Tang
50b59805ca Add more binary service reads. 2026-02-18 10:13:41 -05:00
608 changed files with 45476 additions and 34065 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ maps.key
kls_database.db
.kotlin
lefthook-local.yml
sample-videos/

View File

@@ -1,17 +1,11 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ValueSource
import org.gradle.api.provider.ValueSourceParameters
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.io.File
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.Properties
plugins {
@@ -30,9 +24,9 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1650
val canonicalVersionName = "7.74.2"
val currentHotfixVersion = 0
val canonicalVersionCode = 1660
val canonicalVersionName = "8.1.4"
val currentHotfixVersion = 1
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
@@ -50,6 +44,14 @@ val languagesForBuildConfigProvider = languagesProvider.map { languages ->
languages.joinToString(separator = ", ") { language -> "\"$language\"" }
}
val localPropertiesFile = File(rootProject.projectDir, "local.properties")
val localProperties: Properties? = if (localPropertiesFile.exists()) {
Properties().apply { localPropertiesFile.inputStream().use { load(it) } }
} else {
null
}
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
val selectableVariants = listOf(
"nightlyBackupRelease",
"nightlyBackupSpinner",
@@ -72,6 +74,8 @@ val selectableVariants = listOf(
"playStagingPerf",
"playStagingInstrumentation",
"playStagingRelease",
"playProdQuickstart",
"playStagingQuickstart",
"websiteProdSpinner",
"websiteProdRelease",
"githubProdSpinner",
@@ -259,7 +263,7 @@ android {
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
buildConfigField("boolean", "USE_STRING_ID", "true")
buildConfigField("boolean", "USE_STRING_ID", "false")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -358,8 +362,13 @@ android {
isDebuggable = false
isMinifyEnabled = true
matchingFallbacks += "debug"
applicationIdSuffix = ".benchmark"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
buildConfigField("boolean", "TRACING_ENABLED", "true")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}")
manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.BenchmarkApplicationContext"
}
create("mocked") {
@@ -370,6 +379,8 @@ android {
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
buildConfigField("boolean", "TRACING_ENABLED", "true")
manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.ApplicationContext"
}
create("canary") {
@@ -379,6 +390,14 @@ android {
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Canary\"")
}
create("quickstart") {
initWith(getByName("debug"))
isDefault = false
isMinifyEnabled = false
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Quickstart\"")
}
}
productFlavors {
@@ -445,7 +464,6 @@ android {
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.STAGING")
buildConfigField("int", "LIBSIGNAL_LOG_LEVEL", "org.signal.libsignal.protocol.logging.SignalProtocolLogger.DEBUG")
buildConfigField("boolean", "USE_STRING_ID", "false")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
@@ -502,12 +520,30 @@ android {
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set(tag)
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
}
}
onVariants(selector().withBuildType("quickstart")) { variant ->
val environment = variant.flavorName?.let { name ->
when {
name.contains("staging", ignoreCase = true) -> "staging"
name.contains("prod", ignoreCase = true) -> "prod"
else -> "prod"
}
} ?: "prod"
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
if (quickstartCredentialsDir != null) {
inputDir.set(File(quickstartCredentialsDir))
}
filePrefix.set("${environment}_")
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
}
val releaseDir = "$projectDir/src/release/java"
@@ -535,7 +571,8 @@ android {
applicationVariants.configureEach {
outputs.configureEach {
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
outputFileName = outputFileName.replace(".apk", "-$versionName.apk")
val fileVersionName = versionName.substringBefore(" |")
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
}
}
}
@@ -578,6 +615,7 @@ dependencies {
implementation(project(":core:models"))
implementation(project(":core:models-jvm"))
implementation(project(":feature:camera"))
implementation(project(":feature:registration"))
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.appcompat) {
@@ -773,6 +811,16 @@ fun getNightlyBuildNumber(tag: String?): Int {
return match?.groupValues?.get(1)?.toIntOrNull() ?: 0
}
fun getLastCommitDateTimeUtc(): String {
val timestamp = providers.exec {
commandLine("git", "log", "-1", "--pretty=format:%ct")
}.standardOutput.asText.get().trim().toLong()
val instant = Instant.ofEpochSecond(timestamp)
val formatter = DateTimeFormatter.ofPattern("MMM d '@' HH:mm 'UTC'", Locale.US)
.withZone(ZoneOffset.UTC)
return formatter.format(instant)
}
fun getMapsKey(): String {
return providers
.gradleProperty("mapsKey")
@@ -832,3 +880,38 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
fun String.capitalize(): String {
return this.replaceFirstChar { it.uppercase() }
}
abstract class CopyQuickstartCredentialsTask : DefaultTask() {
@get:InputDirectory
@get:Optional
abstract val inputDir: DirectoryProperty
@get:Input
abstract val filePrefix: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun copy() {
if (!inputDir.isPresent) {
throw GradleException("quickstart.credentials.dir is not set in local.properties. This is required for quickstart builds.")
}
val prefix = filePrefix.get()
val candidates = inputDir.get().asFile.listFiles()
?.filter { it.extension == "json" && it.name.startsWith(prefix) }
?: emptyList()
if (candidates.isEmpty()) {
throw GradleException("No credential files matching '$prefix*.json' found in ${inputDir.get().asFile}. Add files like '${prefix}account1.json' to your credentials directory.")
}
val chosen = candidates.random()
logger.lifecycle("Selected quickstart credential: ${chosen.name}")
val dest = outputDir.get().asFile.resolve("quickstart")
dest.mkdirs()
chosen.copyTo(dest.resolve(chosen.name), overwrite = true)
}
}

View File

@@ -71,6 +71,11 @@ class ArchiveImportExportTests {
runTests { it.startsWith("chat_folder_") }
}
// @Test
fun chatItemAdminDelete() {
runTests { it.startsWith("chat_item_admin_deleted_") }
}
// @Test
fun chatItemContactMessage() {
runTests { it.startsWith("chat_item_contact_message_") }

View File

@@ -18,7 +18,6 @@ import com.bumptech.glide.RequestManager
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
import org.thoughtcrime.securesms.contactshare.Contact
@@ -27,6 +26,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.FakeMessageRecords
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
@@ -209,7 +209,7 @@ class V2ConversationItemShapeTest {
private val nextMessage: MessageRecord? = null
) : V2ConversationContext {
private val colorizer = Colorizer()
private val colorizer = ColorizerV2()
override val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
override val lifecycle: Lifecycle = LifecycleRegistry(this)
@@ -329,7 +329,7 @@ class V2ConversationItemShapeTest {
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) = Unit
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
override fun onItemClick(item: MultiselectPart?) = Unit

View File

@@ -81,8 +81,7 @@ class CallLinkTableTest {
roomId = CallLinkRoomId.fromBytes(roomId),
credentials = CallLinkCredentials(
linkKeyBytes = roomId,
adminPassBytes = null,
epochBytes = null
adminPassBytes = null
),
state = SignalCallLinkState(),
deletionTimestamp = 0L

View File

@@ -0,0 +1,230 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.CursorUtil
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class EditMessageRevisionTest {
@get:Rule
val databaseRule = SignalDatabaseRule()
private lateinit var senderId: RecipientId
private var threadId: Long = 0
@Before
fun setUp() {
val senderAci = ACI.from(UUID.randomUUID())
senderId = SignalDatabase.recipients.getOrInsertFromServiceId(senderAci)
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(senderId, false, ThreadTable.DistributionTypes.DEFAULT)
}
@Test
fun singleEditSetsLatestRevisionIdOnOriginal() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(editId)
assertThat(getLatestRevisionId(editId)).isNull()
}
@Test
fun singleEditOnlyLatestRevisionAppearsInNotificationState() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
val notificationIds = getNotificationStateMessageIds()
assertEquals(listOf(editId), notificationIds)
}
@Test
fun multiEditSetsLatestRevisionIdOnAllPreviousRevisions() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit1Id)
assertThat(getLatestRevisionId(edit1Id)).isNull()
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
}
@Test
fun multiEditOnlyLatestRevisionAppearsInNotificationState() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals("Only the latest revision should appear in notification state", listOf(edit2Id), notificationIds)
}
@Test
fun readSyncThenMultipleEditsDoNotCreateOrphanedUnreadRevisions() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
markAsRead(originalId)
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals(
"No notifications should appear after edits to a message that was already read via sync",
emptyList<Long>(),
notificationIds
)
}
@Test
fun readSyncOnLatestRevisionThenSecondEditDoesNotCreateOrphanedNotification() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
// Read sync updates the latestRevisionId (edit1), not the original
markAsRead(edit1Id)
assertEquals("No notifications after read sync on edited message", 0, getNotificationStateMessageIds().size)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals(
"Only the latest revision or no revisions should appear depending on read state",
notificationIds.filter { it != edit2Id },
emptyList<Long>()
)
}
@Test
fun tripleEditCorrectlyChainsAllRevisions() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val edit3Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1003)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit3Id)
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit3Id)
assertThat(getLatestRevisionId(edit2Id)).isNotNull().isEqualTo(edit3Id)
assertThat(getLatestRevisionId(edit3Id)).isNull()
assertEquals(listOf(edit3Id), getNotificationStateMessageIds())
}
@Test
fun multiEditWithReadSyncBetweenEditsNotificationDismissedAndStaysDismissed() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
assertEquals("Original unread message should be in notification state", 1, getNotificationStateMessageIds().size)
markAsReadAndNotified(originalId)
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
assertEquals("No notifications after first edit (original was read)", 0, getNotificationStateMessageIds().size)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals(
"No notifications should appear - message was read via sync before edits arrived",
emptyList<Long>(),
notificationIds
)
// Verify revision chain integrity
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
val edit1Id = edit2Id - 1 // edit1 was inserted right before edit2
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
assertThat(getLatestRevisionId(edit2Id)).isNull()
}
private fun insertOriginalMessage(sentTimeMillis: Long): Long {
val message = IncomingMessage(
type = MessageType.NORMAL,
from = senderId,
sentTimeMillis = sentTimeMillis,
serverTimeMillis = sentTimeMillis,
receivedTimeMillis = System.currentTimeMillis(),
body = "original message"
)
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
}
/**
* The target is always retrieved via [MessageTable.getMessageFor] using the original sent
* timestamp — this matches what [EditMessageProcessor] does and means targetMessage.id
* is always the original message's row ID.
*/
private fun insertEdit(originalSentTimestamp: Long, editSentTimeMillis: Long): Long {
val targetMessage = SignalDatabase.messages.getMessageFor(originalSentTimestamp, senderId) as MmsMessageRecord
val editMessage = IncomingMessage(
type = MessageType.NORMAL,
from = senderId,
sentTimeMillis = editSentTimeMillis,
serverTimeMillis = editSentTimeMillis,
receivedTimeMillis = System.currentTimeMillis(),
body = "edited at $editSentTimeMillis"
)
return SignalDatabase.messages.insertEditMessageInbox(editMessage, targetMessage).get().messageId
}
private fun getLatestRevisionId(messageId: Long): Long? {
return SignalDatabase.rawDatabase
.query(MessageTable.TABLE_NAME, arrayOf(MessageTable.LATEST_REVISION_ID), "${MessageTable.ID} = ?", arrayOf(messageId.toString()), null, null, null)
.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndexOrThrow(MessageTable.LATEST_REVISION_ID)
if (cursor.isNull(idx)) null else cursor.getLong(idx)
} else {
null
}
}
}
private fun getNotificationStateMessageIds(): List<Long> {
return SignalDatabase.messages.getMessagesForNotificationState(emptyList()).use { cursor ->
val ids = mutableListOf<Long>()
while (cursor.moveToNext()) {
ids.add(CursorUtil.requireLong(cursor, MessageTable.ID))
}
ids
}
}
private fun markAsRead(messageId: Long) {
SignalDatabase.rawDatabase.execSQL(
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1 WHERE ${MessageTable.ID} = ?",
arrayOf(messageId)
)
}
private fun markAsReadAndNotified(messageId: Long) {
SignalDatabase.rawDatabase.execSQL(
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1, ${MessageTable.NOTIFIED} = 1 WHERE ${MessageTable.ID} = ?",
arrayOf(messageId)
)
}
}

View File

@@ -190,7 +190,7 @@ class StorySendTableTest {
@Test
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
SignalDatabase.messages.markAsRemoteDelete(messageId1)
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
@@ -287,7 +287,7 @@ class StorySendTableTest {
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.messages.markAsRemoteDelete(messageId1)
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!

View File

@@ -1,188 +0,0 @@
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import okio.ByteString
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.AliceClient
import org.thoughtcrime.securesms.testing.BobClient
import org.thoughtcrime.securesms.testing.Entry
import org.thoughtcrime.securesms.testing.FakeClientHelpers
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.awaitFor
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.util.regex.Pattern
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import android.util.Log as AndroidLog
/**
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
*/
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
@RunWith(AndroidJUnit4::class)
class MessageProcessingPerformanceTest {
companion object {
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
private val DECRYPTION_TIME_PATTERN = Pattern.compile("^Decrypted (?<count>\\d+) envelopes in (?<duration>\\d+) ms.*$")
}
@get:Rule
val harness = SignalActivityRule()
private val trustRoot: ECKeyPair = ECKeyPair.generate()
@Before
fun setup() {
mockkStatic(SealedSenderAccessUtil::class)
every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkObject(MessageContentProcessor)
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
}
@After
fun after() {
unmockkStatic(SealedSenderAccessUtil::class)
unmockkStatic(MessageContentProcessor::class)
}
@Test
fun testPerformance() {
val aliceClient = AliceClient(
serviceId = harness.self.requireServiceId(),
e164 = harness.self.requireE164(),
trustRoot = trustRoot
)
val bob = Recipient.resolved(harness.others[0])
val bobClient = BobClient(
serviceId = bob.requireServiceId(),
e164 = bob.requireE164(),
identityKeyPair = harness.othersKeys[0],
trustRoot = trustRoot,
profileKey = ProfileKey(bob.profileKey)
)
// Send the initial messages to get past the prekey phase
establishSession(aliceClient, bobClient, bob)
// Have Bob generate N messages that will be received by Alice
val messageCount = 100
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
val firstTimestamp = envelopes.first().timestamp
val lastTimestamp = envelopes.last().timestamp ?: 0
// Inject the envelopes into the websocket
// TODO: mock websocket messages
// Wait until they've all been fully decrypted + processed
harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
.awaitFor(1.minutes)
harness.inMemoryLogger.flush()
// Process logs for timing data
val entries = harness.inMemoryLogger.entries()
// Calculate decryption average
val totalDecryptDuration: Long = entries
.mapNotNull { entry -> entry.message?.let { DECRYPTION_TIME_PATTERN.matcher(it) } }
.filter { it.matches() }
.drop(1) // Ignore the first message, which represents the prekey exchange
.sumOf { it.group("duration")!!.toLong() }
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / messageCount.toFloat()}ms")
// Calculate MessageContentProcessor
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
val iterator = takeLast.iterator()
var processCount = 0L
var processDuration = 0L
while (iterator.hasNext()) {
val start = iterator.next()
val end = iterator.next()
processCount++
processDuration += end.timestamp - start.timestamp
}
AndroidLog.w(TAG, "MessageContentProcessor.process: Average runtime: ${processDuration.toFloat() / processCount.toFloat()}ms")
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
val messagePerSecond = messageCount.toFloat() / duration
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
}
private fun establishSession(aliceClient: AliceClient, bobClient: BobClient, bob: Recipient) {
// Send message from Bob to Alice (self)
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
val aliceProcessFirstMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
// Send message from Alice to Bob
val aliceNow = System.currentTimeMillis()
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
}
private fun generateInboundEnvelopes(bobClient: BobClient, count: Int): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()
for (i in 0..count) {
envelopes += bobClient.encrypt(now)
now += 3
}
return envelopes
}
private fun webSocketTombstone(): ByteString {
return WebSocketMessage(request = WebSocketRequestMessage(verb = "PUT", path = "/api/v1/queue/empty")).encodeByteString()
}
private fun Envelope.toWebSocketPayload(): ByteString {
return WebSocketMessage(
type = WebSocketMessage.Type.REQUEST,
request = WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random(System.currentTimeMillis()).nextLong(),
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
body = this.encodeByteString()
)
).encodeByteString()
}
}

View File

@@ -1,28 +0,0 @@
package org.thoughtcrime.securesms.messages
import android.content.Context
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testing.LogPredicate
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.Envelope
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
companion object {
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
entry.tag == TAG && entry.message == endTag(timestamp)
}
private fun startTag(timestamp: Long) = "$timestamp start"
fun endTag(timestamp: Long) = "$timestamp end"
}
override fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
Log.d(TAG, startTag(envelope.timestamp!!))
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric)
Log.d(TAG, endTag(envelope.timestamp!!))
}
}

View File

@@ -1,58 +0,0 @@
package org.thoughtcrime.securesms.testing
import org.signal.core.models.ServiceId
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Envelope
/**
* Welcome to Alice's Client.
*
* Alice represent the Android instrumentation test user. Unlike [BobClient] much less is needed here
* as it can make use of the standard Signal Android App infrastructure.
*/
class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECKeyPair) {
companion object {
val TAG = Log.tag(AliceClient::class.java)
}
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
trustRoot = trustRoot,
uuid = serviceId.rawUuid,
e164 = e164,
deviceId = 1,
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
expires = 31337
)
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
val start = System.currentTimeMillis()
val bufferedStore = BufferedProtocolStore.create()
AppDependencies.incomingMessageObserver
.processEnvelope(bufferedStore, envelope, serverDeliveredTimestamp)
?.mapNotNull { it.run() }
?.forEach { it.enqueue() }
bufferedStore.flushToDisk()
val end = System.currentTimeMillis()
Log.d(TAG, "${end - start}")
}
fun encrypt(now: Long, destination: Recipient): Envelope {
return AppDependencies.signalServiceMessageSender.getEncryptedMessage(
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
FakeClientHelpers.getSealedSenderAccess(ProfileKey(destination.profileKey), aliceSenderCertificate),
1,
FakeClientHelpers.encryptedTextMessage(now),
false
).toEnvelope(now, destination.requireServiceId())
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import android.app.Application
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobmanager.JobMigrator
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.FastJobStorage
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.IndividualSendJob
import org.thoughtcrime.securesms.jobs.JobManagerFactories
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.jobs.ReactionSendJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
import org.thoughtcrime.securesms.jobs.TypingSendJob
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.function.Supplier
import kotlin.time.Duration.Companion.seconds
class BenchmarkApplicationContext : ApplicationContext() {
override fun initializeAppDependencies() {
AppDependencies.init(this, BenchmarkDependencyProvider(this, ApplicationDependencyProvider(this)))
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
}
override fun onForeground() = Unit
class BenchmarkDependencyProvider(val application: Application, private val default: ApplicationDependencyProvider) : AppDependencies.Provider by default {
override fun provideAuthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.AuthenticatedWebSocket {
return SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideUnauthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.UnauthenticatedWebSocket {
return SignalWebSocket.UnauthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideJobManager(): JobManager {
val config = JobManager.Configuration.Builder()
.setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application)))
.setConstraintFactories(JobManagerFactories.getConstraintFactories(application))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(application))
.setJobStorage(FastJobStorage(JobDatabase.getInstance(application)))
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application)))
.addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
.addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
.addReservedJobRunner(
FactoryJobPredicate(
IndividualSendJob.KEY,
PushGroupSendJob.KEY,
ReactionSendJob.KEY,
TypingSendJob.KEY,
GroupCallUpdateSendJob.KEY,
SendDeliveryReceiptJob.KEY
)
)
.build()
return JobManager(application, config)
}
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
val blockedJobs = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
CreateReleaseChannelJob.KEY,
DirectoryRefreshJob.KEY,
DownloadLatestEmojiDataJob.KEY,
EmojiSearchIndexDownloadJob.KEY,
FontDownloaderJob.KEY,
GroupRingCleanupJob.KEY,
GroupV2UpdateSelfProfileKeyJob.KEY,
LinkedDeviceInactiveCheckJob.KEY,
MultiDeviceProfileKeyUpdateJob.KEY,
PostRegistrationBackupRedemptionJob.KEY,
PreKeysSyncJob.KEY,
ProfileUploadJob.KEY,
RefreshAttributesJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
return jobFactories.mapValues {
if (it.key in blockedJobs) {
NoOpJob.Factory()
} else {
it.value
}
}
}
}
private class NoOpJob(parameters: Parameters) : Job(parameters) {
companion object {
const val KEY = "NoOpJob"
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<NoOpJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob {
return NoOpJob(parameters)
}
}
}
}

View File

@@ -1,16 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<application
android:name="${applicationClass}"
tools:replace="name">
<profileable android:shell="true" />
<activity android:name="org.signal.benchmark.BenchmarkSetupActivity"
<activity
android:name="org.signal.benchmark.BenchmarkSetupActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
android:exported="true"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
android:windowSoftInputMode="stateHidden" />
<receiver
android:name="org.signal.benchmark.BenchmarkCommandReceiver"
android:exported="true">
<intent-filter>
<action android:name="org.signal.benchmark.action.COMMAND" />
</intent-filter>
</receiver>
</application>
</manifest>
</manifest>

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.benchmark
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
* A BroadcastReceiver that accepts commands sent from the benchmark app to perform
* background operations on the client.
*/
class BenchmarkCommandReceiver : BroadcastReceiver() {
companion object {
private val TAG = Log.tag(BenchmarkCommandReceiver::class)
const val ACTION_COMMAND = "org.signal.benchmark.action.COMMAND"
const val EXTRA_COMMAND = "command"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_COMMAND) {
Log.w(TAG, "Ignoring unknown action: ${intent.action}")
return
}
val command = intent.getStringExtra(EXTRA_COMMAND)
Log.i(TAG, "Received command: $command")
when (command) {
"individual-send" -> handlePrepareIndividualSend()
"group-send" -> handlePrepareGroupSend()
"group-delivery-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundDeliveryReceipts(timestamps) }
"group-read-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundReadReceipts(timestamps) }
"release-messages" -> {
BenchmarkWebSocketConnection.authInstance.startWholeBatchTrace = true
BenchmarkWebSocketConnection.authInstance.releaseMessages()
}
else -> Log.w(TAG, "Unknown command: $command")
}
}
private fun handlePrepareIndividualSend() {
val client = Harness.otherClients[0]
// Send message from Bob to Self
val encryptedEnvelope = client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial message form Bob to establish session.")
addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
releaseMessages()
// Sleep briefly to let the message be processed.
ThreadUtil.sleep(100)
}
}
}
// Have Bob generate N messages that will be received by Alice
val messageCount = 100
val envelopes = client.generateInboundEnvelopes(messageCount)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
}
private fun handlePrepareGroupSend() {
val clients = Harness.otherClients.take(5)
// Send message from others to Self in the group
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
releaseMessages()
// Sleep briefly to let the messages be processed.
ThreadUtil.sleep(1000)
}
}
}
// Have clients generate N group messages that will be received by Alice
clients.forEach { client ->
val messageCount = 100
val envelopes = client.generateInboundGroupEnvelopes(messageCount, Harness.groupMasterKey)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
}
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
}
private fun handlePrepareGroupReceipts(generateReceipts: (OtherClient, List<Long>) -> List<Envelope>) {
val clients = Harness.otherClients.take(5)
establishGroupSessions(clients)
val timestamps = getOutgoingGroupMessageTimestamps()
Log.i(TAG, "Found ${timestamps.size} outgoing message timestamps for receipts")
val allClientEnvelopes = clients.map { client ->
generateReceipts(client, timestamps).map { it.toWebSocketPayload() }
}
BenchmarkWebSocketConnection.authInstance.addPendingMessages(interleave(allClientEnvelopes))
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
}
private fun establishGroupSessions(clients: List<OtherClient>) {
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
releaseMessages()
ThreadUtil.sleep(1000)
}
}
}
}
private fun getOutgoingGroupMessageTimestamps(): List<Long> {
val groupId = GroupId.v2(Harness.groupMasterKey)
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val selfId = Recipient.self().id.toLong()
return TestDbUtils.getOutgoingMessageTimestamps(threadId, selfId)
}
/**
* Interleaves lists so that items from different lists alternate:
* [[a1, a2], [b1, b2], [c1, c2]] -> [a1, b1, c1, a2, b2, c2]
*/
private fun <T> interleave(lists: List<List<T>>): List<T> {
val result = mutableListOf<T>()
val maxSize = lists.maxOf { it.size }
for (i in 0 until maxSize) {
for (list in lists) {
if (i < list.size) {
result += list[i]
}
}
}
return result
}
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage {
return WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random.nextLong(),
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
body = this.encodeByteString()
)
}
}

View File

@@ -6,7 +6,10 @@ import org.signal.benchmark.setup.TestMessages
import org.signal.benchmark.setup.TestUsers
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.TextSecurePreferences
class BenchmarkSetupActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -15,6 +18,10 @@ class BenchmarkSetupActivity : BaseActivity() {
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
}
val textView: TextView = TextView(this).apply {
@@ -56,4 +63,50 @@ class BenchmarkSetupActivity : BaseActivity() {
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
}
}
private fun setupMessageSend() {
TestUsers.setupSelf()
TestUsers.setupTestClients(1)
}
private fun setupGroupMessageSend() {
TestUsers.setupSelf()
TestUsers.setupGroup()
}
private fun setupGroupReceipt(includeMsl: Boolean = false, enableReadReceipts: Boolean = false) {
TestUsers.setupSelf()
val groupId = TestUsers.setupGroup()
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val messageIds = mutableListOf<Long>()
val timestamps = mutableListOf<Long>()
val baseTimestamp = 2_000_000L
for (i in 0 until 100) {
val timestamp = baseTimestamp + i
val message = OutgoingMessage(
recipient = groupRecipient,
body = "Outgoing message $i",
timestamp = timestamp,
isSecure = true
)
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
SignalDatabase.messages.markAsSent(insert.messageId, true)
messageIds += insert.messageId
timestamps += timestamp
}
if (includeMsl) {
val selfId = Recipient.self().id
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
TestDbUtils.insertMessageSendLogEntries(messageIds, timestamps, memberRecipientIds)
}
if (enableReadReceipts) {
TextSecurePreferences.setReadReceiptsEnabled(this, true)
}
}
}

View File

@@ -1,57 +1,71 @@
package org.thoughtcrime.securesms.testing
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.benchmark.setup
import okio.ByteString.Companion.toByteString
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.toByteArray
import org.signal.libsignal.metadata.certificate.CertificateValidator
import org.signal.libsignal.metadata.certificate.SenderCertificate
import org.signal.libsignal.metadata.certificate.ServerCertificate
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.GroupContextV2
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.ReceiptMessage
import java.util.Optional
import java.util.UUID
object FakeClientHelpers {
object Generator {
val noOpCertificateValidator = object : CertificateValidator(ECKeyPair.generate().publicKey) {
override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
}
fun createCertificateFor(trustRoot: ECKeyPair, uuid: UUID, e164: String, deviceId: Int, identityKey: ECPublicKey, expires: Long): SenderCertificate {
val serverKey: ECKeyPair = ECKeyPair.generate()
val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey)
return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.of(e164), deviceId, identityKey, expires)
}
fun getSealedSenderAccess(theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): SealedSenderAccess? {
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
}
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
fun encryptedTextMessage(
now: Long,
message: String = "Test message",
groupMasterKey: GroupMasterKey? = null
): EnvelopeContent {
val content = Content.Builder().apply {
dataMessage(
DataMessage.Builder().buildWith {
body = message
timestamp = now
if (groupMasterKey != null) {
groupV2 = GroupContextV2.Builder().buildWith {
masterKey = groupMasterKey.serialize().toByteString()
revision = 1
}
}
}
)
}
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
}
fun encryptedDeliveryReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
return encryptedReceipt(ReceiptMessage.Type.DELIVERY, timestamps)
}
fun encryptedReadReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
return encryptedReceipt(ReceiptMessage.Type.READ, timestamps)
}
private fun encryptedReceipt(type: ReceiptMessage.Type, timestamps: List<Long>): EnvelopeContent {
val content = Content.Builder().apply {
receiptMessage(
ReceiptMessage.Builder().buildWith {
this.type = type
timestamp = timestamps
}
)
}
return EnvelopeContent.encrypted(content.build(), ContentHint.IMPLICIT, Optional.empty())
}
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
val serverGuid = UUID.randomUUID()
return Envelope.Builder()

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.benchmark.setup
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.UuidUtil
import org.signal.libsignal.metadata.certificate.SenderCertificate
import org.signal.libsignal.metadata.certificate.ServerCertificate
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import java.util.Optional
import java.util.UUID
import kotlin.random.Random
import kotlin.time.Duration
object Harness {
const val SELF_E164 = "+15555559999"
val SELF_ACI = ACI.from(UuidUtil.parseOrThrow("d81b9a54-0ec9-43aa-a73f-7e99280ad53e"))
private val OTHERS_IDENTITY_KEY = IdentityKeyPair(Base64.decode("CiEFbAw403SCGPB+tjqfk+jrH7r9ma1P2hcujqydHRYVzzISIGiWYdWYBBdBzDdF06wgEm+HKcc6ETuWB7Jnvk7Wjw1u"))
private val OTHERS_PROFILE_KEY = ProfileKey(Base64.decode("aJJ/A7GBCSnU9HJ1DdMWcKMMeXQKRUguTlAbtlfo/ik"))
val groupMasterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
val trustRoot = ECKeyPair(
ECPublicKey(Base64.decode("BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv")),
ECPrivateKey(Base64.decode("2B1zU7JQdPol/XWiom4pQXrSrHFeO8jzZ1u7wfrtY3o"))
)
val otherClients: List<OtherClient> by lazy {
val random = Random(4242)
buildList {
(0 until 1000).forEach { i ->
val aci = ACI.from(UUID(random.nextLong(), random.nextLong()))
val e164 = "+1555555%04d".format(i)
val identityKey = OTHERS_IDENTITY_KEY
val profileKey = OTHERS_PROFILE_KEY
add(OtherClient(aci, e164, identityKey, profileKey))
}
}
}
fun createCertificateFor(uuid: UUID, e164: String?, deviceId: Int, identityKey: ECPublicKey, expires: Duration): SenderCertificate {
val serverKey: ECKeyPair = ECKeyPair.generate()
val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey)
return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.ofNullable(e164), deviceId, identityKey, expires.inWholeMilliseconds)
}
}

View File

@@ -1,13 +1,16 @@
package org.thoughtcrime.securesms.testing
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.benchmark.setup
import org.signal.benchmark.setup.Generator.toEnvelope
import org.signal.core.models.ServiceId
import org.signal.core.util.readToSingleInt
import org.signal.core.util.select
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SessionBuilder
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
import org.signal.libsignal.protocol.state.IdentityKeyStore
@@ -18,40 +21,36 @@ import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.database.KyberPreKeyTable
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
import org.whispersystems.signalservice.api.SignalSessionLock
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Envelope
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
/**
* Welcome to Bob's Client.
*
* Bob is a "fake" client that can start a session with the Android instrumentation test user (Alice).
*
* Bob can create a new session using a prekey bundle created from Alice's prekeys, send a message, decrypt
* a return message from Alice, and that'll start a standard Signal session with normal keys/ratcheting.
* This is a "fake" client that can start a session with the running app's user, referred to as Alice in this
* code.
*/
class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val trustRoot: ECKeyPair, val profileKey: ProfileKey) {
class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val profileKey: ProfileKey) {
private val serviceAddress = SignalServiceAddress(serviceId, e164)
private val registrationId = KeyHelper.generateRegistrationId(false)
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, 31337)
private val senderCertificate = Harness.createCertificateFor(serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, System.currentTimeMillis().milliseconds + 30.days)
private val sessionLock = object : SignalSessionLock {
private val lock = ReentrantLock()
@@ -62,9 +61,11 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
fun encrypt(now: Long): Envelope {
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
fun encrypt(envelopeContent: EnvelopeContent): Envelope {
return encrypt(envelopeContent, envelopeContent.content.get().dataMessage!!.timestamp!!)
}
fun encrypt(envelopeContent: EnvelopeContent, timestamp: Long): Envelope {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
@@ -73,12 +74,47 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
.toEnvelope(timestamp, getAliceServiceId())
}
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
cipher.decrypt(envelope, serverDeliveredTimestamp)
fun generateInboundEnvelopes(count: Int): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()
for (i in 0 until count) {
envelopes += encrypt(Generator.encryptedTextMessage(now))
now += 3
}
return envelopes
}
fun generateInboundDeliveryReceipts(messageTimestamps: List<Long>): List<Envelope> {
return generateInboundReceipts(messageTimestamps, Generator::encryptedDeliveryReceipt)
}
fun generateInboundReadReceipts(messageTimestamps: List<Long>): List<Envelope> {
return generateInboundReceipts(messageTimestamps, Generator::encryptedReadReceipt)
}
private fun generateInboundReceipts(messageTimestamps: List<Long>, receiptFactory: (Long, List<Long>) -> EnvelopeContent): List<Envelope> {
val envelopes = ArrayList<Envelope>(messageTimestamps.size)
var now = System.currentTimeMillis()
for (messageTimestamp in messageTimestamps) {
envelopes += encrypt(receiptFactory(now, listOf(messageTimestamp)), now)
now += 3
}
return envelopes
}
fun generateInboundGroupEnvelopes(count: Int, groupMasterKey: GroupMasterKey): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()
for (i in 0 until count) {
envelopes += encrypt(Generator.encryptedTextMessage(now, groupMasterKey = groupMasterKey))
now += 3
}
return envelopes
}
private fun getAliceServiceId(): ServiceId {
@@ -86,45 +122,22 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
private fun getAlicePreKeyBundle(): PreKeyBundle {
val selfPreKeyId = SignalDatabase.rawDatabase
.select(OneTimePreKeyTable.KEY_ID)
.from(OneTimePreKeyTable.TABLE_NAME)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val aliceSignedPreKeyRecord = SignalDatabase.signedPreKeys.getAll(getAliceServiceId()).first()
val selfPreKeyRecord = SignalDatabase.oneTimePreKeys.get(getAliceServiceId(), selfPreKeyId)!!
val selfSignedPreKeyId = SignalDatabase.rawDatabase
.select(SignedPreKeyTable.KEY_ID)
.from(SignedPreKeyTable.TABLE_NAME)
.where("${SignedPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
val selfSignedKyberPreKeyId = SignalDatabase.rawDatabase
.select(KyberPreKeyTable.KEY_ID)
.from(KyberPreKeyTable.TABLE_NAME)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val selfSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.get(getAliceServiceId(), selfSignedKyberPreKeyId)!!.record
val aliceSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.getAllLastResort(getAliceServiceId()).first().record
return PreKeyBundle(
SignalStore.account.registrationId,
1,
selfPreKeyId,
selfPreKeyRecord.keyPair.publicKey,
selfSignedPreKeyId,
selfSignedPreKeyRecord.keyPair.publicKey,
selfSignedPreKeyRecord.signature,
getAlicePublicKey(),
selfSignedKyberPreKeyId,
selfSignedKyberPreKeyRecord.keyPair.publicKey,
selfSignedKyberPreKeyRecord.signature
registrationId = SignalStore.account.registrationId,
deviceId = 1,
preKeyId = PreKeyBundle.NULL_PRE_KEY_ID,
preKeyPublic = null,
signedPreKeyId = aliceSignedPreKeyRecord.id,
signedPreKeyPublic = aliceSignedPreKeyRecord.keyPair.publicKey,
signedPreKeySignature = aliceSignedPreKeyRecord.signature,
identityKey = getAlicePublicKey(),
kyberPreKeyId = aliceSignedKyberPreKeyRecord.id,
kyberPreKeyPublic = aliceSignedKyberPreKeyRecord.keyPair.publicKey,
kyberPreKeySignature = aliceSignedKyberPreKeyRecord.signature
)
}
@@ -141,7 +154,10 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
private fun getAliceUnidentifiedAccess(): SealedSenderAccess? {
return FakeClientHelpers.getSealedSenderAccess(getAliceProfileKey(), senderCertificate)
val theirProfileKey = getAliceProfileKey()
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
}
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
@@ -152,7 +168,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
override fun getLocalRegistrationId(): Int = registrationId
override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true
override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord()
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityKeyStore.IdentityChange = IdentityChange.NEW_OR_UNCHANGED
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityChange = IdentityChange.NEW_OR_UNCHANGED
override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) {
aliceSessionRecord = record
}

View File

@@ -4,15 +4,25 @@ import android.app.Application
import android.content.SharedPreferences
import android.preference.PreferenceManager
import kotlinx.coroutines.runBlocking
import okio.ByteString
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.Util
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.storageservice.storage.protos.groups.AccessControl
import org.signal.storageservice.storage.protos.groups.Member
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer
import org.signal.storageservice.storage.protos.groups.local.EnabledState
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.CertificateType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
@@ -28,11 +38,12 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.util.UUID
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
object TestUsers {
private var generatedOthers: Int = 0
private val TEST_E164 = "+15555550101"
private var generatedOthers: Int = 1
fun setupSelf(): Recipient {
val application: Application = AppDependencies.application
@@ -50,19 +61,19 @@ object TestUsers {
runBlocking {
val registrationData = RegistrationData(
code = "123123",
e164 = TEST_E164,
e164 = Harness.SELF_E164,
password = Util.getSecret(18),
registrationId = RegistrationRepository.getRegistrationId(),
profileKey = RegistrationRepository.getProfileKey(TEST_E164),
profileKey = RegistrationRepository.getProfileKey(Harness.SELF_E164),
fcmToken = null,
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
recoveryPassword = "asdfasdfasdfasdf"
)
val remoteResult = AccountRegistrationResult(
uuid = UUID.randomUUID().toString(),
uuid = Harness.SELF_ACI.toString(),
pni = UUID.randomUUID().toString(),
storageCapable = false,
number = TEST_E164,
number = Harness.SELF_E164,
masterKey = null,
pin = null,
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
@@ -78,6 +89,31 @@ object TestUsers {
RegistrationUtil.maybeMarkRegistrationComplete()
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
TextSecurePreferences.setPromptedOptimizeDoze(application, true)
TextSecurePreferences.setRatingEnabled(application, false)
PreKeyUtil.generateAndStoreSignedPreKey(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
PreKeyUtil.generateAndStoreOneTimeEcPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
val aliceSenderCertificate = Harness.createCertificateFor(
uuid = Harness.SELF_ACI.rawUuid,
e164 = Harness.SELF_E164,
deviceId = 1,
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
expires = System.currentTimeMillis().milliseconds + 30.days
)
val aliceSenderCertificate2 = Harness.createCertificateFor(
uuid = Harness.SELF_ACI.rawUuid,
e164 = null,
deviceId = 1,
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
expires = System.currentTimeMillis().milliseconds + 30.days
)
SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_AND_E164, aliceSenderCertificate.serialized)
SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_ONLY, aliceSenderCertificate2.serialized)
return Recipient.self()
}
@@ -111,4 +147,73 @@ object TestUsers {
return others
}
fun setupTestClients(othersCount: Int): List<RecipientId> {
val others = mutableListOf<RecipientId>()
synchronized(this) {
for (i in 0 until othersCount) {
val otherClient = Harness.otherClients[i]
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)
others += recipientId
}
generatedOthers += othersCount
}
return others
}
fun setupGroup(): GroupId.V2 {
val members = setupTestClients(5)
val self = Recipient.self()
val fullMembers = buildList {
add(member(aci = self.requireAci()))
addAll(members.map { member(aci = Recipient.resolved(it).requireAci()) })
}
val group = DecryptedGroup(
title = "Title",
avatar = "",
disappearingMessagesTimer = DecryptedTimer(),
accessControl = AccessControl(),
revision = 1,
members = fullMembers,
pendingMembers = emptyList(),
requestingMembers = emptyList(),
inviteLinkPassword = ByteString.EMPTY,
description = "Description",
isAnnouncementGroup = EnabledState.DISABLED,
bannedMembers = emptyList(),
isPlaceholderGroup = false
)
val groupId = SignalDatabase.groups.create(
groupMasterKey = Harness.groupMasterKey,
groupState = group,
groupSendEndorsements = null
)
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId!!).id, true)
return groupId
}
private fun member(aci: ACI, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0, labelEmoji: String = "", labelString: String = ""): DecryptedMember {
return DecryptedMember(
role = role,
aciBytes = aci.toByteString(),
joinedAtRevision = joinedAt,
labelEmoji = labelEmoji,
labelString = labelString
)
}
}

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import org.signal.core.util.SqlUtil.buildArgs
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.Content
object TestDbUtils {
@@ -11,4 +13,58 @@ object TestDbUtils {
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
}
fun getOutgoingMessageTimestamps(threadId: Long, selfRecipientId: Long): List<Long> {
val timestamps = mutableListOf<Long>()
SignalDatabase.messages.databaseHelper.signalReadableDatabase.query(
MessageTable.TABLE_NAME,
arrayOf(MessageTable.DATE_SENT),
"${MessageTable.THREAD_ID} = ? AND ${MessageTable.FROM_RECIPIENT_ID} = ?",
arrayOf(threadId.toString(), selfRecipientId.toString()),
null,
null,
"${MessageTable.DATE_SENT} ASC"
).use { cursor ->
while (cursor.moveToNext()) {
timestamps += cursor.getLong(0)
}
}
return timestamps
}
fun insertMessageSendLogEntries(messageIds: List<Long>, timestamps: List<Long>, recipientIds: List<RecipientId>) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
val dummyContent = Content.Builder().build().encode()
db.beginTransaction()
try {
for (i in messageIds.indices) {
val payloadValues = ContentValues().apply {
put("date_sent", timestamps[i])
put("content", dummyContent)
put("content_hint", 0)
put("urgent", 1)
}
val payloadId = db.insert("msl_payload", null, payloadValues)
val messageValues = ContentValues().apply {
put("payload_id", payloadId)
put("message_id", messageIds[i])
}
db.insert("msl_message", null, messageValues)
for (recipientId in recipientIds) {
val recipientValues = ContentValues().apply {
put("payload_id", payloadId)
put("recipient_id", recipientId.toLong())
put("device", 1)
}
db.insert("msl_recipient", null, recipientValues)
}
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.internal.websocket
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.push.SendMessageResponse
import java.net.SocketException
import java.util.LinkedList
import java.util.Optional
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
/**
* A [WebSocketConnection] that provides a way to add "incoming" WebSocket payloads
* and have client code pull them off the "wire" as they would a normal socket.
*
* Add messages with [addPendingMessages] and then can release them to the requestor via
* [releaseMessages].
*/
class BenchmarkWebSocketConnection : WebSocketConnection {
companion object {
lateinit var authInstance: BenchmarkWebSocketConnection
private set
@Synchronized
fun createAuthInstance(): WebSocketConnection {
authInstance = BenchmarkWebSocketConnection()
return authInstance
}
lateinit var unauthInstance: BenchmarkWebSocketConnection
private set
@Synchronized
fun createUnauthInstance(): WebSocketConnection {
unauthInstance = BenchmarkWebSocketConnection()
return unauthInstance
}
}
override val name: String = "bench-${System.identityHashCode(this)}"
private val state = BehaviorSubject.create<WebSocketConnectionState>()
private val incomingRequests = LinkedList<WebSocketRequestMessage>()
private val incomingSemaphore = Semaphore(0)
var startWholeBatchTrace = false
@Volatile
private var isShutdown = false
override fun connect(): Observable<WebSocketConnectionState> {
state.onNext(WebSocketConnectionState.CONNECTED)
return state
}
override fun isDead(): Boolean {
return false
}
override fun disconnect() {
state.onNext(WebSocketConnectionState.DISCONNECTED)
// Signal shutdown
isShutdown = true
val queuedThreads = incomingSemaphore.queueLength
if (queuedThreads > 0) {
incomingSemaphore.release(queuedThreads)
}
}
override fun readRequest(timeoutMillis: Long): WebSocketRequestMessage {
if (incomingSemaphore.tryAcquire(1, 10, TimeUnit.SECONDS)) {
// Check if we were woken up due to shutdown
if (isShutdown) {
throw SocketException("WebSocket connection closed")
}
return getNextRequest()
}
throw TimeoutException("Timeout exceeded")
}
override fun readRequestIfAvailable(): Optional<WebSocketRequestMessage> {
return if (incomingSemaphore.tryAcquire()) {
Optional.of(getNextRequest())
} else {
Optional.empty()
}
}
private fun getNextRequest(): WebSocketRequestMessage {
if (startWholeBatchTrace) {
startWholeBatchTrace = false
SignalTrace.beginSection("IncomingMessageObserver#totalProcessing")
}
return incomingRequests.removeFirst()
}
override fun sendResponse(response: WebSocketResponseMessage) = Unit
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
incomingRequests.addAll(messages)
}
fun releaseMessages() {
incomingSemaphore.release(incomingRequests.size)
}
override fun sendRequest(
request: WebSocketRequestMessage,
timeoutSeconds: Long
): Single<WebsocketResponse> {
if (request.verb != null && request.path != null) {
if (request.verb == "PUT" && request.path!!.startsWith("/v1/messages/")) {
return Single.just(WebsocketResponse(200, SendMessageResponse().toJson(), emptyList<String>(), true))
}
}
return Single.error(okio.IOException("fake timeout"))
}
override fun sendKeepAlive() = Unit
fun addQueueEmptyMessage() {
addPendingMessages(
listOf(
WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/queue/empty"
)
)
)
}
}
private fun Any.toJson(): String {
return JsonUtils.toJson(this)
}

View File

@@ -105,7 +105,6 @@ class ConversationElementGenerator {
false,
emptyList(),
false,
false,
now,
true,
now,
@@ -122,6 +121,7 @@ class ConversationElementGenerator {
0,
false,
0,
null,
null
)

View File

@@ -18,7 +18,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -31,7 +30,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapterV2
@@ -68,7 +67,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
requestManager = Glide.with(this),
clickListener = ClickListener(),
hasWallpaper = springboardViewModel.hasWallpaper.value,
colorizer = Colorizer(),
colorizer = ColorizerV2(),
startExpirationTimeout = {},
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) },
displayDialogFragment = {}
@@ -295,7 +294,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}

View File

@@ -752,7 +752,7 @@
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity
android:name=".registration.ui.restore.local.InternalNewLocalRestoreActivity"
android:name=".registration.ui.restore.local.RestoreLocalBackupActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
@@ -933,6 +933,11 @@
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity
android:name=".groups.memberlabel.MemberLabelActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<!-- ======================================= -->
<!-- Activity Aliases -->
<!-- ======================================= -->

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import androidx.lifecycle.Observer;
import com.bumptech.glide.RequestManager;
import org.signal.ringrtc.CallLinkEpoch;
import org.signal.ringrtc.CallLinkRootKey;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
@@ -30,8 +29,8 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
import org.thoughtcrime.securesms.polls.PollRecord;
import org.thoughtcrime.securesms.polls.PollOption;
import org.thoughtcrime.securesms.polls.PollRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
@@ -136,7 +135,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
void onEditedIndicatorClicked(@NonNull ConversationMessage conversationMessage);
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey, @Nullable CallLinkEpoch callLinkEpoch);
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey);
void onShowSafetyTips(boolean forGroup);
void onReportSpamLearnMoreClicked();
void onMessageRequestAcceptOptionsClicked();

View File

@@ -342,6 +342,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
selectionLimit,
isMulti,
new ContactSearchAdapter.DisplayOptions(
isMulti,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
@@ -558,6 +559,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void onDataRefreshed() {
this.resetPositionOnCommit = true;
swipeRefresh.setRefreshing(false);
contactSearchMediator.refresh();
}
public boolean hasQueryFilter() {
@@ -574,6 +576,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void reset() {
contactSearchMediator.clearSelection();
contactSearchMediator.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}

View File

@@ -97,7 +97,6 @@ import org.signal.mediasend.MediaSendActivityContract
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
@@ -140,6 +139,7 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
import org.thoughtcrime.securesms.main.MainMegaphoneState
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationViewModel
@@ -156,7 +156,6 @@ import org.thoughtcrime.securesms.main.chatNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
@@ -447,7 +446,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
val mutableInteractionSource = remember { MutableInteractionSource() }
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
@@ -477,25 +476,33 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
storiesNavGraphBuilder()
}
LaunchedEffect(mainNavigationDetailLocation) {
mainNavigationViewModel.clearEarlyDetailLocation()
when (mainNavigationDetailLocation) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(mainNavigationDetailLocation)
}
LaunchedEffect(Unit) {
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
}
is MainNavigationDetailLocation.Chats -> {
if (location is MainNavigationDetailLocation.Chats.Conversation) {
chatNavGraphState.writeGraphicsLayerToBitmap()
}
chatsNavHostController.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) }
mainNavigationViewModel.clearEarlyDetailLocation()
mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) }
}
val scope = rememberCoroutineScope()
@@ -752,27 +759,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
val coroutine = rememberCoroutineScope()
return remember(scaffoldNavigator, coroutine) {
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
when (detailLocation) {
is MainNavigationDetailLocation.Chats.Conversation -> {
startActivity(
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
.withArgs(detailLocation.conversationArgs)
.build()
)
}
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
}
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
error("Unexpected subroute EditCallLinkName.")
}
MainNavigationDetailLocation.Empty -> Unit
}
}
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator)
}
}

View File

@@ -1,65 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.concurrent.TimeUnit;
public class MuteDialog extends AlertDialog {
protected MuteDialog(Context context) {
super(context);
}
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
protected MuteDialog(Context context, int theme) {
super(context, theme);
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
show(context, listener, null);
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
builder.setTitle(R.string.MuteDialog_mute_notifications);
builder.setItems(R.array.mute_durations, (dialog, which) -> {
final long muteUntil;
switch (which) {
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
case 4: muteUntil = Long.MAX_VALUE; break;
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
}
listener.onMuted(muteUntil);
});
if (cancelListener != null) {
builder.setOnCancelListener(dialog -> {
cancelListener.run();
dialog.dismiss();
});
}
builder.show();
}
public interface MuteSelectionListener {
public void onMuted(long until);
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.components.settings.conversation.MuteUntilTimePickerBottomSheet
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
object MuteDialog {
private const val MUTE_UNTIL: Long = -1L
private data class MuteOption(
@DrawableRes val iconRes: Int,
val title: String,
val duration: Long
)
@JvmStatic
fun show(context: Context, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: MuteSelectionListener) {
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
action.onMuted(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
}
val options = listOf(
MuteOption(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour), 1.hours.inWholeMilliseconds),
MuteOption(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours), 8.hours.inWholeMilliseconds),
MuteOption(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day), 1.days.inWholeMilliseconds),
MuteOption(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days), 7.days.inWholeMilliseconds),
MuteOption(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until), MUTE_UNTIL),
MuteOption(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always), Long.MAX_VALUE)
)
val adapter = object : BaseAdapter() {
override fun getCount(): Int = options.size
override fun getItem(position: Int): MuteOption = options[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.mute_dialog_item, parent, false)
val option = options[position]
view.findViewById<ImageView>(R.id.mute_dialog_icon).setImageResource(option.iconRes)
view.findViewById<TextView>(R.id.mute_dialog_title).text = option.title
return view
}
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.MuteDialog_mute_notifications)
.setAdapter(adapter) { _, which ->
val option = options[which]
when (option.duration) {
MUTE_UNTIL -> MuteUntilTimePickerBottomSheet.show(fragmentManager)
Long.MAX_VALUE -> action.onMuted(Long.MAX_VALUE)
else -> action.onMuted(System.currentTimeMillis() + option.duration)
}
}
.show()
}
fun interface MuteSelectionListener {
fun onMuted(until: Long)
}
}

View File

@@ -153,7 +153,7 @@ abstract class Attachment(
* Denotes whether the media for the given attachment is no longer available for download.
*/
val isMediaNoLongerAvailableForDownload: Boolean
get() = isPermanentlyFailed && uploadTimestamp.milliseconds > 30.days
get() = isPermanentlyFailed && (System.currentTimeMillis().milliseconds - uploadTimestamp.milliseconds) > 30.days
val isSticker: Boolean
get() = stickerLocator != null

View File

@@ -214,6 +214,10 @@ object ExportOddities {
return log(0, "Distribution list had self as a member. Removing it.")
}
fun quoteAuthorNotFound(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote author was not found in the exported recipients. Removing the quote.")
}
fun emptyQuote(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
}
@@ -283,6 +287,10 @@ object ImportSkips {
return log(0, "Missing recipient for chat $chatId")
}
fun missingAdminDeleteRecipient(sentTimestamp: Long, chatId: Long): String {
return log(sentTimestamp, "Missing admin delete recipient for chat $chatId")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}

View File

@@ -44,7 +44,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
${MessageTable.FROM_RECIPIENT_ID},
${MessageTable.TO_RECIPIENT_ID},
${MessageTable.EXPIRE_STARTED},
${MessageTable.REMOTE_DELETED},
${MessageTable.UNIDENTIFIED},
${MessageTable.LINK_PREVIEWS},
${MessageTable.SHARED_CONTACTS},
@@ -68,7 +67,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
${MessageTable.VIEW_ONCE},
${MessageTable.PINNED_UNTIL},
${MessageTable.PINNING_MESSAGE_ID},
${MessageTable.PINNED_AT}
${MessageTable.PINNED_AT},
${MessageTable.DELETED_BY}
)
WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1
""".trimMargin()
@@ -136,7 +136,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
MessageTable.TO_RECIPIENT_ID,
EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
MessageTable.REMOTE_DELETED,
MessageTable.UNIDENTIFIED,
MessageTable.LINK_PREVIEWS,
MessageTable.SHARED_CONTACTS,
@@ -161,7 +160,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
PARENT_STORY_ID,
MessageTable.PINNED_UNTIL,
MessageTable.PINNING_MESSAGE_ID,
MessageTable.PINNED_AT
MessageTable.PINNED_AT,
MessageTable.DELETED_BY
)
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")

View File

@@ -40,7 +40,6 @@ class CallLinkArchiveExporter(private val cursor: Cursor) : Iterator<ArchiveReci
id = callLink.recipientId.toLong(),
callLink = CallLink(
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
epoch = callLink.credentials.epochBytes?.takeIf { it.size == 4 }?.toByteString(),
adminKey = callLink.credentials.adminPassBytes?.toByteString()?.nullIfEmpty(),
name = callLink.state.name,
expirationMs = expirationTime.takeIf { it != Long.MAX_VALUE }?.clampToValidBackupRange() ?: 0,

View File

@@ -19,7 +19,6 @@ import org.signal.core.util.UuidUtil
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.emptyIfNull
import org.signal.core.util.isEmpty
import org.signal.core.util.isNotEmpty
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.kibiBytes
@@ -42,6 +41,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ExportOddities
import org.thoughtcrime.securesms.backup.v2.ExportSkips
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.proto.AdminDeletedMessage
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
@@ -193,11 +193,16 @@ class ChatItemArchiveExporter(
}
when {
record.remoteDeleted -> {
record.deletedBy == record.fromRecipientId -> {
builder.remoteDeletedMessage = RemoteDeletedMessage()
transformTimer.emit("remote-delete")
}
record.deletedBy != null -> {
builder.adminDeletedMessage = AdminDeletedMessage(adminId = record.deletedBy)
transformTimer.emit("admin-delete")
}
MessageTypes.isJoinedType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
transformTimer.emit("simple-update")
@@ -564,7 +569,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
}
val direction = when {
record.type.isDirectionlessType() && !record.remoteDeleted -> {
record.type.isDirectionlessType() && record.deletedBy == null -> {
Direction.DIRECTIONLESS
}
MessageTypes.isOutgoingMessageType(record.type) || record.fromRecipientId == selfRecipientId.toLong() -> {
@@ -1169,6 +1174,11 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
return null
}
if (!exportState.recipientIds.contains(this.quoteAuthor)) {
Log.w(TAG, ExportOddities.quoteAuthorNotFound(this.dateSent))
return null
}
val localType = QuoteModel.Type.fromCode(this.quoteType)
val remoteType = when (localType) {
QuoteModel.Type.NORMAL -> {
@@ -1360,11 +1370,12 @@ private fun FailureReason?.toRemote(): PaymentNotification.TransactionDetails.Fa
}
private fun List<Mention>.toRemoteBodyRanges(exportState: ExportState): List<BackupBodyRange> {
return this.map {
return this.mapNotNull {
val aci = exportState.recipientIdToAci[it.recipientId.toLong()] ?: return@mapNotNull null
BackupBodyRange(
start = it.start,
length = it.length,
mentionAci = exportState.recipientIdToAci[it.recipientId.toLong()]
mentionAci = aci
)
}
}
@@ -1662,7 +1673,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
this.giftBadge == null &&
this.viewOnceMessage == null &&
this.directStoryReplyMessage == null &&
this.poll == null
this.poll == null &&
this.adminDeletedMessage == null
) {
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
return null
@@ -1805,7 +1817,6 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID),
expiresIn = expiresIn,
expireStarted = expireStarted,
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
linkPreview = this.requireString(MessageTable.LINK_PREVIEWS),
sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS),
@@ -1830,6 +1841,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
pinnedAt = this.requireLong(MessageTable.PINNED_AT),
pinnedUntil = this.requireLong(MessageTable.PINNED_UNTIL),
deletedBy = this.requireLongOrNull(MessageTable.DELETED_BY),
messageExtrasSize = messageExtras?.size ?: 0
)
}
@@ -1847,7 +1859,6 @@ private class BackupMessageRecord(
val toRecipientId: Long,
val expiresIn: Long,
val expireStarted: Long,
val remoteDeleted: Boolean,
val sealedSender: Boolean,
val linkPreview: String?,
val sharedContacts: String?,
@@ -1872,6 +1883,7 @@ private class BackupMessageRecord(
val viewOnce: Boolean,
val pinnedAt: Long,
val pinnedUntil: Long,
val deletedBy: Long?,
private val messageExtrasSize: Int
) {
val estimatedSizeInBytes: Int = (body?.length ?: 0) +

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.backup.v2.util.clampToValidBackupRange
import org.thoughtcrime.securesms.backup.v2.util.isValidUsername
import org.thoughtcrime.securesms.backup.v2.util.toRemote
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
@@ -75,7 +76,7 @@ class ContactArchiveExporter(private val cursor: Cursor, private val selfId: Lon
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { Base64.decode(it) }?.toByteString())
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { ProfileKeyUtil.profileKeyOrNull(it)?.serialize()?.toByteString() })
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME))
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME))

View File

@@ -116,7 +116,7 @@ private fun AccessControl.toRemote(): Group.AccessControl {
private fun Member.Role.toRemote(): Group.Member.Role {
return when (this) {
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
Member.Role.UNKNOWN -> Group.Member.Role.DEFAULT
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
}

View File

@@ -42,11 +42,7 @@ object CallLinkArchiveImporter {
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey),
credentials = CallLinkCredentials(
callLink.rootKey.toByteArray(),
callLink.epoch?.toByteArray(),
callLink.adminKey?.toByteArray()
),
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
state = SignalCallLinkState(
name = callLink.name,
restrictions = callLink.restrictions.toLocal(),

View File

@@ -121,7 +121,6 @@ class ChatItemArchiveImporter(
MessageTable.EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
MessageTable.UNIDENTIFIED,
MessageTable.REMOTE_DELETED,
MessageTable.NETWORK_FAILURES,
MessageTable.QUOTE_ID,
MessageTable.QUOTE_AUTHOR,
@@ -141,7 +140,8 @@ class ChatItemArchiveImporter(
MessageTable.NOTIFIED,
MessageTable.PINNED_UNTIL,
MessageTable.PINNING_MESSAGE_ID,
MessageTable.PINNED_AT
MessageTable.PINNED_AT,
MessageTable.DELETED_BY
)
private val REACTION_COLUMNS = arrayOf(
@@ -193,6 +193,12 @@ class ChatItemArchiveImporter(
Log.w(TAG, ImportSkips.chatIdRemoteRecipientNotFound(chatItem.dateSent, chatItem.chatId))
return
}
if (chatItem.adminDeletedMessage != null && importState.remoteToLocalRecipientId[chatItem.adminDeletedMessage.adminId] == null) {
Log.w(TAG, ImportSkips.missingAdminDeleteRecipient(chatItem.dateSent, chatItem.chatId))
return
}
val messageInsert = chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
if (chatItem.revisions.isNotEmpty()) {
// Flush to avoid having revisions cross batch boundaries, which will cause a foreign key failure
@@ -672,7 +678,6 @@ class ChatItemArchiveImporter(
contentValues.put(MessageTable.QUOTE_MISSING, 0)
contentValues.put(MessageTable.QUOTE_TYPE, 0)
contentValues.put(MessageTable.VIEW_ONCE, 0)
contentValues.put(MessageTable.REMOTE_DELETED, 0)
contentValues.put(MessageTable.PARENT_STORY_ID, 0)
if (this.pinDetails != null) {
@@ -683,12 +688,13 @@ class ChatItemArchiveImporter(
when {
this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage)
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage, fromRecipientId, toRecipientId)
this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
this.giftBadge != null -> contentValues.addGiftBadge(this.giftBadge)
this.viewOnceMessage != null -> contentValues.addViewOnce(this.viewOnceMessage)
this.directStoryReplyMessage != null -> contentValues.addDirectStoryReply(this.directStoryReplyMessage, toRecipientId)
this.adminDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, importState.remoteToLocalRecipientId[this.adminDeletedMessage.adminId]!!.toLong())
}
return contentValues

View File

@@ -33,8 +33,6 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.BetaHeader
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
import org.signal.core.ui.R as CoreUiR
/**
@@ -65,10 +63,6 @@ fun MessageBackupsEducationScreen(
.fillMaxWidth()
.weight(1f)
) {
item {
BetaHeader()
}
item {
Image(
painter = painterResource(id = R.drawable.image_signal_backups),
@@ -80,9 +74,9 @@ fun MessageBackupsEducationScreen(
}
item {
TextWithBetaLabel(
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 15.dp)
)
}

View File

@@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallException
import org.signal.ringrtc.CallLinkEpoch
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.DatabaseObserver
@@ -23,7 +22,6 @@ import java.net.URLDecoder
*/
object CallLinks {
private const val ROOT_KEY = "key"
private const val EPOCH = "epoch"
private const val LEGACY_HTTPS_LINK_PREFIX = "https://signal.link/call#key="
private const val LEGACY_SGNL_LINK_PREFIX = "sgnl://signal.link/call#key="
private const val HTTPS_LINK_PREFIX = "https://signal.link/call/#key="
@@ -31,13 +29,7 @@ object CallLinks {
private val TAG = Log.tag(CallLinks::class.java)
fun url(rootKeyBytes: ByteArray, epochBytes: ByteArray?): String {
return if (epochBytes == null) {
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}"
} else {
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}&epoch=${CallLinkEpoch.fromBytes(epochBytes)}"
}
}
fun url(rootKeyBytes: ByteArray): String = "$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}"
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
return Observable.create { emitter ->
@@ -78,13 +70,8 @@ object CallLinks {
return url.split("#").last().startsWith("key=")
}
data class CallLinkParseResult(
val rootKey: CallLinkRootKey,
val epoch: CallLinkEpoch?
)
@JvmStatic
fun parseUrl(url: String): CallLinkParseResult? {
fun parseUrl(url: String): CallLinkRootKey? {
if (!isPrefixedCallLink(url)) {
Log.w(TAG, "Invalid url prefix.")
return null
@@ -132,13 +119,9 @@ object CallLinks {
}
return try {
val epoch = fragmentQuery[EPOCH]?.let { s -> CallLinkEpoch(s) }
CallLinkParseResult(
rootKey = CallLinkRootKey(key),
epoch = epoch
)
return CallLinkRootKey(key)
} catch (e: CallException) {
Log.w(TAG, "Invalid root key or epoch found in fragment query string.")
Log.w(TAG, "Invalid root key found in fragment query string.")
null
}
}

View File

@@ -53,7 +53,7 @@ import org.signal.core.ui.R as CoreUiR
@Composable
private fun SignalCallRowPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(0, 1, 2, 3), byteArrayOf(5, 6, 7, 8))
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(5, 6, 7, 8))
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 3, 5, 7)),
@@ -97,7 +97,7 @@ fun SignalCallRow(
"https://signal.call.example.com"
} else {
remember(callLink.credentials) {
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes, it.epochBytes) } ?: ""
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: ""
}
}

View File

@@ -162,7 +162,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
startActivity(
ShareActivity.sendSimpleText(
requireContext(),
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes))
)
)
}
@@ -176,7 +176,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
@@ -191,7 +191,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
is EnsureCallLinkCreatedResult.Success -> {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
.setText(CallLinks.url(viewModel.linkKeyBytes))
.setType(mimeType)
.createChooserIntent()

View File

@@ -52,9 +52,6 @@ class CreateCallLinkViewModel(
val linkKeyBytes: ByteArray
get() = callLink.value.credentials!!.linkKeyBytes
val epochBytes: ByteArray?
get() = callLink.value.credentials!!.epochBytes
private val internalShowAlreadyInACall = MutableStateFlow(false)
val showAlreadyInACall: StateFlow<Boolean> = internalShowAlreadyInACall

View File

@@ -119,7 +119,7 @@ class DefaultCallLinkDetailsCallback(
override fun onShareClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(activity)
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
.setText(CallLinks.url(viewModel.rootKeySnapshot))
.setType(mimeType)
.createChooserIntent()
@@ -131,7 +131,7 @@ class DefaultCallLinkDetailsCallback(
}
override fun onCopyClicked() {
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot))
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
@@ -139,7 +139,7 @@ class DefaultCallLinkDetailsCallback(
activity.startActivity(
ShareActivity.sendSimpleText(
activity,
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot))
)
)
}
@@ -324,7 +324,6 @@ private fun CallLinkDetailsScreenPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(
byteArrayOf(1, 2, 3, 4),
byteArrayOf(0, 1, 2, 3),
byteArrayOf(3, 4, 5, 6)
)
CallLinkTable.CallLink(

View File

@@ -48,9 +48,6 @@ class CallLinkDetailsViewModel(
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
val epochSnapshot: ByteArray?
get() = state.value.callLink?.credentials?.epochBytes
private val recipientSubject = BehaviorSubject.create<Recipient>()
val recipientSnapshot: Recipient?
get() = recipientSubject.value

View File

@@ -219,9 +219,16 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private fun handleDeleteSelectedRows() {
val count = callLogActionMode.getCount()
val selectionState = viewModel.selectionStateSnapshot
val hasCallLinks = selectionState.isExclusionary() || selectionState.selected().any { it is CallLogRow.Id.CallLink }
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
.apply {
if (hasCallLinks) {
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
}
}
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
performDeletion(count, viewModel.stageSelectionDeletion())
callLogActionMode.end()
@@ -380,7 +387,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
.apply {
if (call is CallLogRow.CallLink) {
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
}
}
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
performDeletion(1, viewModel.stageCallDeletion(call))
}

View File

@@ -84,7 +84,7 @@ private fun NewCallScreen(
val context = LocalActivity.current as FragmentActivity
val callbacks = remember {
object : UiCallbacks {
object : NewCallUiCallbacks {
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
override fun onRecipientSelected(selection: RecipientSelection) = viewModel.startCall(selection)
override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context))
@@ -111,7 +111,7 @@ private fun NewCallScreen(
)
}
private interface UiCallbacks :
private interface NewCallUiCallbacks :
RecipientPickerCallbacks.ListActions,
RecipientPickerCallbacks.Refresh,
RecipientPickerCallbacks.NewCall {
@@ -120,7 +120,7 @@ private interface UiCallbacks :
fun onUserMessageDismissed(userMessage: UserMessage)
fun onBackPressed()
object Empty : UiCallbacks {
object Empty : NewCallUiCallbacks {
override fun onSearchQueryChanged(query: String) = Unit
override fun onRecipientSelected(selection: RecipientSelection) = Unit
override fun onInviteToSignal() = Unit
@@ -134,7 +134,7 @@ private interface UiCallbacks :
@Composable
private fun NewCallScreenUi(
uiState: NewCallUiState,
callbacks: UiCallbacks
callbacks: NewCallUiCallbacks
) {
val snackbarHostState = remember { SnackbarHostState() }
@@ -173,7 +173,7 @@ private fun NewCallScreenUi(
}
@Composable
private fun TopAppBarActions(callbacks: UiCallbacks) {
private fun TopAppBarActions(callbacks: NewCallUiCallbacks) {
val menuController = remember { DropdownMenus.MenuController() }
IconButton(
onClick = { menuController.show() },
@@ -250,7 +250,7 @@ private fun NewCallScreenPreview() {
uiState = NewCallUiState(
forceSplitPane = false
),
callbacks = UiCallbacks.Empty
callbacks = NewCallUiCallbacks.Empty
)
}
}

View File

@@ -15,6 +15,7 @@ import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.View;
import android.view.Menu;
import android.view.MenuItem;
import android.view.inputmethod.EditorInfo;
@@ -66,6 +67,7 @@ public class ComposeText extends EmojiEditText {
private MentionRendererDelegate mentionRendererDelegate;
private SpoilerRendererDelegate spoilerRendererDelegate;
private MentionValidatorWatcher mentionValidatorWatcher;
private MessageSendType lastMessageSendType;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@@ -221,6 +223,11 @@ public class ComposeText extends EmojiEditText {
}
public void setMessageSendType(MessageSendType messageSendType) {
if (messageSendType.equals(lastMessageSendType)) {
return;
}
lastMessageSendType = messageSendType;
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType();
@@ -278,6 +285,10 @@ public class ComposeText extends EmojiEditText {
}
private void initialize() {
if (Build.VERSION.SDK_INT >= 26) {
setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
}
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}

View File

@@ -47,7 +47,10 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
@Override
public void onResume() {
super.onResume();
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
if (getShowsDialog()) {
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
}
}
protected void onNavigateUp() {

View File

@@ -64,6 +64,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private var insets: WindowInsetsCompat? = null
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
private var navigationBarInsetOverride: Int? = null
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
this.insets = insets
@@ -114,6 +115,23 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
}
fun setNavigationBarInsetOverride(inset: Int?) {
if (navigationBarInsetOverride == inset) return
navigationBarInsetOverride = inset
if (inset != null) {
// Apply immediately so layout is correct before next inset dispatch (important for
// Android 15 bubble where insets can arrive late or with different values).
navigationBarGuideline?.setGuidelineEnd(inset)
if (!isKeyboardShowing) {
keyboardGuideline?.setGuidelineEnd(inset)
}
requestLayout()
}
if (insets != null) {
applyInsets(insets!!.getInsets(windowTypes), insets!!.getInsets(keyboardType))
}
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners += listener
}
@@ -134,7 +152,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
val isLtr = ViewUtil.isLtr(this)
val statusBar = windowInsets.top
val navigationBar = if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
ViewUtil.getNavigationBarHeight(resources)
} else {
windowInsets.bottom

View File

@@ -172,11 +172,11 @@ public class LinkPreviewView extends FrameLayout {
spinner.setVisibility(GONE);
noPreview.setVisibility(GONE);
CallLinks.CallLinkParseResult callLinkParseResult = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
CallLinkRootKey callLinkRootKey = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
if (!Util.isEmpty(linkPreview.getTitle())) {
title.setText(linkPreview.getTitle());
title.setVisibility(VISIBLE);
} else if (callLinkParseResult != null) {
} else if (callLinkRootKey != null) {
title.setText(R.string.Recipient_signal_call);
title.setVisibility(VISIBLE);
} else {
@@ -186,7 +186,7 @@ public class LinkPreviewView extends FrameLayout {
if (showDescription && !Util.isEmpty(linkPreview.getDescription())) {
description.setText(linkPreview.getDescription());
description.setVisibility(VISIBLE);
} else if (callLinkParseResult != null) {
} else if (callLinkRootKey != null) {
description.setText(R.string.LinkPreviewView__use_this_link_to_join_a_signal_call);
description.setVisibility(VISIBLE);
} else {
@@ -221,14 +221,14 @@ public class LinkPreviewView extends FrameLayout {
thumbnail.get().setImageResource(requestManager, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION && !scheduleMessageMode, false);
thumbnail.get().showSecondaryText(false);
thumbnail.get().setOutlineEnabled(true);
} else if (callLinkParseResult != null) {
} else if (callLinkRootKey != null) {
thumbnail.setVisibility(VISIBLE);
thumbnailState.applyState(thumbnail);
thumbnail.get().setImageDrawable(
requestManager,
new FallbackAvatarDrawable(
getContext(),
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(callLinkParseResult.getRootKey().getKeyBytes()))
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(callLinkRootKey.getKeyBytes()))
).circleCrop()
);
thumbnail.get().showSecondaryText(false);
@@ -272,7 +272,7 @@ public class LinkPreviewView extends FrameLayout {
thumbnailState.applyState(thumbnail);
}
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {
return customError == LinkPreviewRepository.Error.GROUP_LINK_INACTIVE ? R.string.LinkPreviewView_this_group_link_is_not_active
: R.string.LinkPreviewView_no_link_preview_available;
}

View File

@@ -6,6 +6,7 @@ import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import org.signal.core.ui.initializeScreenshotSecurity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.fragments.findListener
@@ -57,6 +58,11 @@ abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_containe
}
}
override fun onResume() {
super.onResume()
dialog?.window?.initializeScreenshotSecurity()
}
open fun onHandleBackPressed() {
dismissAllowingStateLoss()
}

View File

@@ -1,136 +0,0 @@
package org.thoughtcrime.securesms.components.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
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.Rows
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
/**
* Adds a 'Beta' label next to [text] to indicate a feature is in development
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TextWithBetaLabel(
text: String,
textStyle: TextStyle = TextStyle.Default,
enabled: Boolean = true,
modifier: Modifier = Modifier
) {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.Center,
modifier = modifier
) {
Text(
text = text,
style = textStyle,
modifier = Modifier
.align(Alignment.CenterVertically)
.alpha(if (enabled) 1f else Rows.DISABLED_ALPHA)
)
Text(
text = stringResource(R.string.Beta__beta_title).uppercase(),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.padding(start = 6.dp)
.padding(vertical = 6.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(28.dp))
.padding(horizontal = 12.dp, vertical = 4.dp)
.alpha(if (enabled) 1f else Rows.DISABLED_ALPHA)
.align(Alignment.CenterVertically)
)
}
}
/**
* 'Beta' header to indicate a feature is currently in development
*/
@Composable
fun BetaHeader(modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.background(
color = SignalTheme.colors.colorSurface2,
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp)
.fillMaxWidth()
) {
Icon(
imageVector = SignalIcons.Info.imageVector,
contentDescription = stringResource(id = R.string.Beta__info),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(id = R.string.Beta__this_is_beta),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 12.dp)
)
}
}
@DayNightPreviews
@Composable
fun BetaLabelPreview() {
Previews.Preview {
TextWithBetaLabel("Signal Backups")
}
}
@DayNightPreviews
@Composable
fun BetaLabelDisabledPreview() {
Previews.Preview {
TextWithBetaLabel("Signal Backups", enabled = false)
}
}
@Preview(locale = "de")
@Composable
fun LongTextBetaLabelPreview() {
Previews.Preview {
Scaffold {
TextWithBetaLabel(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(it)
)
}
}
}
@DayNightPreviews
@Composable
fun BetaHeaderPreview() {
Previews.Preview {
BetaHeader()
}
}

View File

@@ -264,6 +264,8 @@ public class EmojiTextView extends AppCompatTextView {
previousOverflowText = overflowText;
useSystemEmoji = useSystemEmoji();
previousTransformationMethod = getTransformationMethod();
lastSizeChangedWidth = -1;
lastSizeChangedHeight = -1;
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
@@ -590,7 +592,7 @@ public class EmojiTextView extends AppCompatTextView {
lastSizeChangedWidth = w;
lastSizeChangedHeight = h;
if (!sizeChangeInProgress) {
if (!sizeChangeInProgress && getMaxLines() > 0 && getMaxLines() < Integer.MAX_VALUE) {
sizeChangeInProgress = true;
resetText();
}

View File

@@ -5,11 +5,14 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -21,10 +24,12 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.TextUnit
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
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.
@@ -34,6 +39,7 @@ import org.signal.core.ui.compose.Previews
@Composable
fun Emojifier(
text: String,
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
Text(
text = annotatedText,
@@ -41,38 +47,56 @@ fun Emojifier(
)
}
) {
if (LocalInspectionMode.current) {
if (useSystemEmoji) {
content(buildAnnotatedString { append(text) }, emptyMap())
return
}
val context = LocalContext.current
val candidates = remember(text) { EmojiProvider.getCandidates(text) }
val candidateMap: Map<String, InlineTextContent> = remember(text) {
candidates?.associate { candidate ->
candidate.drawInfo.emoji to InlineTextContent(placeholder = Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)) {
Image(
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, candidate.drawInfo.emoji)),
contentDescription = null
)
}
} ?: emptyMap()
val fontSize = LocalTextStyle.current.fontSize
val foundEmojis: List<EmojiParser.Candidate> = remember(text) {
EmojiProvider.getCandidates(text)?.list.orEmpty()
}
val inlineContentByEmoji: Map<String, InlineTextContent> = remember(text, fontSize) {
foundEmojis.associate { it.drawInfo.emoji to createInlineContent(context, it.drawInfo.emoji, fontSize) }
}
val annotatedString = buildAnnotatedString {
append(text)
val annotatedString = remember(text) { buildAnnotatedString(text, foundEmojis) }
content(annotatedString, inlineContentByEmoji)
}
candidates?.forEach {
addStringAnnotation(
tag = "EMOJI",
annotation = it.drawInfo.emoji,
start = it.startIndex,
end = it.endIndex
)
private fun createInlineContent(context: Context, emoji: String, fontSize: TextUnit): InlineTextContent {
return InlineTextContent(
placeholder = Placeholder(width = fontSize, height = fontSize, PlaceholderVerticalAlign.TextCenter)
) {
Image(
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, emoji)),
contentDescription = null
)
}
}
/**
* Constructs an [AnnotatedString] from [text], substituting each emoji in [foundEmojis] with an inline content placeholder.
*/
private fun buildAnnotatedString(
text: String,
foundEmojis: List<EmojiParser.Candidate>
): AnnotatedString = buildAnnotatedString {
var nextSegmentStartIndex = 0
foundEmojis.forEach { emoji ->
if (emoji.startIndex > nextSegmentStartIndex) {
append(text, start = nextSegmentStartIndex, end = emoji.startIndex)
}
appendInlineContent(emoji.drawInfo.emoji)
nextSegmentStartIndex = emoji.endIndex
}
content(annotatedString, candidateMap)
if (nextSegmentStartIndex < text.length) {
append(text, start = nextSegmentStartIndex, end = text.length)
}
}
@Composable

View File

@@ -41,6 +41,9 @@ public class EmojiParser {
this.emojiTree = emojiTree;
}
/**
* Returns an ordered list of every emoji occurrence found in the given text.
*/
public @NonNull CandidateList findCandidates(@Nullable CharSequence text) {
List<Candidate> results = new LinkedList<>();

View File

@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -52,7 +52,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
AppSettingsRoute.Empty -> null
is AppSettingsRoute.BackupsRoute.Local -> {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow))) {
AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
.setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
} else {

View File

@@ -68,7 +68,6 @@ import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
@@ -415,10 +414,9 @@ private fun AppSettingsContent(
item {
Rows.TextRow(
text = {
TextWithBetaLabel(
Text(
text = stringResource(R.string.preferences_chats__backups),
textStyle = MaterialTheme.typography.bodyLarge,
enabled = isRegisteredAndUpToDate
style = MaterialTheme.typography.bodyLarge
)
},
icon = {

View File

@@ -166,8 +166,7 @@ class BackupStateObserver(
}
val price = latestPayment.data.amount!!.toFiatMoney()
val isKeepAlive = latestPayment.data.redemption?.keepAlive == true
val isPending = latestPayment.state == InAppPaymentTable.State.PENDING && !isKeepAlive
val isPending = SignalDatabase.inAppPayments.hasPendingBackupRedemption()
if (isPending) {
Log.d(TAG, "[getDatabaseBackupState] We have a pending subscription.")
return BackupState.Pending(price = price)
@@ -243,8 +242,7 @@ class BackupStateObserver(
* Utilizes everything we can to resolve the most accurate backup state available, including database and network.
*/
private suspend fun getNetworkBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
val isKeepAlive = lastPurchase?.data?.redemption?.keepAlive == true
if (lastPurchase?.state == InAppPaymentTable.State.PENDING && !isKeepAlive) {
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
Log.d(TAG, "[getNetworkBackupState] We have a pending subscription.")
return BackupState.Pending(
price = lastPurchase.data.amount!!.toFiatMoney()

View File

@@ -53,12 +53,11 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
@@ -105,13 +104,12 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !SignalStore.settings.isBackupEnabled) {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
} else {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
}
},
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
)
}
@@ -123,7 +121,6 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onNewOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
) {
Scaffolds.Settings(
@@ -242,16 +239,6 @@ private fun BackupsSettingsContent(
onClick = onOnDeviceBackupsRowClick
)
}
if (backupsSettingsState.showNewLocalBackup) {
item {
Rows.TextRow(
text = "INTERNAL ONLY - New Local Backup",
label = "Use new local backup format",
onClick = onNewOnDeviceBackupsRowClick
)
}
}
}
}
}
@@ -285,9 +272,9 @@ private fun NeverEnabledBackupsRow(
},
text = {
Column {
TextWithBetaLabel(
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge
)
Text(
@@ -331,9 +318,9 @@ private fun InactiveBackupsRow(
Rows.TextRow(
text = {
Column {
TextWithBetaLabel(
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge
)
Text(
@@ -377,9 +364,9 @@ private fun NotFoundBackupRow(
},
text = {
Column {
TextWithBetaLabel(
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge
)
Text(
@@ -412,9 +399,9 @@ private fun PendingBackupRow(
},
text = {
Column {
TextWithBetaLabel(
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge
)
Text(
@@ -463,9 +450,9 @@ private fun LocalStoreBackupRow(
},
text = {
Column {
TextWithBetaLabel(
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge
)
val tierText = when (backupState.tier) {
@@ -508,9 +495,9 @@ private fun ActiveBackupsRow(
},
text = {
Column {
TextWithBetaLabel(
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
textStyle = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge
)
when (val type = backupState.messageBackupsType) {

View File

@@ -17,6 +17,5 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null,
val showNewLocalBackup: Boolean = false
val backupTierInternalOverride: MessageBackupTier? = null
)

View File

@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.time.Duration.Companion.milliseconds
class BackupsSettingsViewModel : ViewModel() {
@@ -46,8 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
}
}

View File

@@ -1,275 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
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.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.formatHours
import java.time.LocalTime
import java.util.Locale
/**
* App settings internal screen for enabling and creating new local backups.
*/
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private var createStatus by mutableStateOf("None")
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chooseBackupLocationLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
handleBackupLocationSelected(result.data!!.data!!)
} else {
Log.w(TAG, "Backup location selection cancelled or failed")
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: LocalBackupV2Event) {
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
}
@Composable
override fun FragmentContent() {
val context = LocalContext.current
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
InternalLocalBackupScreen(
backupsEnabled = backupsEnabled,
selectedDirectory = selectedDirectory,
lastBackupTimeString = lastBackupTimeString,
backupTime = backupTime,
createStatus = createStatus,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
if (latestDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
}
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Launching backup directory picker")
chooseBackupLocationLauncher.launch(intent)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch backup directory picker", e)
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
private fun handleBackupLocationSelected(uri: Uri) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
}
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
context,
Locale.getDefault(),
lastBackupTimestamp
)
if (relativeTime.isRelative) {
relativeTime.value
} else {
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
val time = relativeTime.value
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
}
} else {
context.getString(R.string.RemoteBackupsSettingsFragment__never)
}
}
private inner class CallbackImpl : Callback {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onToggleBackupsClick(enabled: Boolean) {
SignalStore.backup.newLocalBackupsEnabled = enabled
if (enabled) {
LocalBackupListener.schedule(requireContext())
}
}
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onEnqueueBackupClick() {
createStatus = "Starting..."
LocalBackupJob.enqueueArchive(false)
}
}
}
private interface Callback {
fun onNavigationClick()
fun onToggleBackupsClick(enabled: Boolean)
fun onSelectDirectoryClick()
fun onEnqueueBackupClick()
object Empty : Callback {
override fun onNavigationClick() = Unit
override fun onToggleBackupsClick(enabled: Boolean) = Unit
override fun onSelectDirectoryClick() = Unit
override fun onEnqueueBackupClick() = Unit
}
}
@Composable
private fun InternalLocalBackupScreen(
backupsEnabled: Boolean = false,
selectedDirectory: String? = null,
lastBackupTimeString: String = "Never",
backupTime: String = "Unknown",
createStatus: String = "None",
callback: Callback
) {
Scaffolds.Settings(
title = "New Local Backups",
navigationIcon = SignalIcons.ArrowStart.imageVector,
onNavigationClick = callback::onNavigationClick
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Rows.ToggleRow(
checked = backupsEnabled,
text = "Enable New Local Backups",
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
onCheckChanged = callback::onToggleBackupsClick
)
}
item {
Rows.TextRow(
text = "Last Backup",
label = lastBackupTimeString
)
}
item {
Rows.TextRow(
text = "Backup Schedule Time (same as v1)",
label = backupTime
)
}
item {
Rows.TextRow(
text = "Select Backup Directory",
label = selectedDirectory ?: "No directory selected",
onClick = callback::onSelectDirectoryClick
)
}
item {
Rows.TextRow(
text = "Create Backup Now",
label = "Enqueue LocalArchiveJob",
onClick = callback::onEnqueueBackupClick
)
}
item {
Rows.TextRow(
text = "Create Status",
label = createStatus
)
}
}
}
}
@DayNightPreviews
@Composable
fun InternalLocalBackupScreenPreview() {
Previews.Preview {
InternalLocalBackupScreen(
backupsEnabled = true,
selectedDirectory = "/storage/emulated/0/Signal/Backups",
lastBackupTimeString = "1 hour ago",
callback = Callback.Empty
)
}
}

View File

@@ -4,13 +4,11 @@
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -31,6 +29,7 @@ import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Launchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
@@ -158,11 +157,10 @@ class LocalBackupsFragment : ComposeFragment() {
}
@Composable
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
val context = LocalContext.current
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == Activity.RESULT_OK && uri != null) {
return Launchers.rememberOpenDocumentTreeLauncher { uri ->
if (uri != null) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)

View File

@@ -6,9 +6,7 @@ package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract
import android.net.Uri
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
@@ -52,7 +50,7 @@ sealed interface LocalBackupsSettingsCallback {
class DefaultLocalBackupsSettingsCallback(
private val fragment: LocalBackupsFragment,
private val chooseBackupLocationLauncher: ActivityResultLauncher<Intent>,
private val chooseBackupLocationLauncher: ActivityResultLauncher<Uri?>,
private val viewModel: LocalBackupsViewModel
) : LocalBackupsSettingsCallback {
@@ -65,22 +63,10 @@ class DefaultLocalBackupsSettingsCallback(
}
override fun onLaunchBackupLocationPickerClick() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings.latestSignalBackupDirectory)
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Starting choose backup location dialog")
chooseBackupLocationLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {
chooseBackupLocationLauncher.launch(SignalStore.settings.latestSignalBackupDirectory)
} catch (_: ActivityNotFoundException) {
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}

View File

@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.formatHours
import java.text.NumberFormat
@@ -96,7 +95,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
val clientDeprecated = SignalStore.misc.isClientDeprecated
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
if (SignalStore.backup.newLocalBackupsEnabled) {
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {

View File

@@ -101,7 +101,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusRow
import org.thoughtcrime.securesms.backup.v2.ui.status.RestoreType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.components.compose.BetaHeader
import org.thoughtcrime.securesms.components.compose.BiometricsAuthentication
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
@@ -419,10 +418,6 @@ private fun RemoteBackupsSettingsContent(
modifier = Modifier
.padding(it)
) {
item {
BetaHeader(modifier = Modifier.padding(horizontal = 16.dp))
}
if (state.isOutOfStorageSpace) {
item {
OutOfStorageSpaceBlock(

View File

@@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.payments.DataExportUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -164,6 +165,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
if (BuildConfig.DEBUG) {
clickPref(
title = DSLSettingsText.from("Export quickstart credentials"),
summary = DSLSettingsText.from("Export registration credentials to a JSON file for quickstart builds."),
onClick = {
exportQuickstartCredentials()
}
)
}
clickPref(
title = DSLSettingsText.from("Unregister"),
summary = DSLSettingsText.from("This will unregister your account without deleting it."),
@@ -787,6 +798,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Add remote backups note"),
onClick = {
viewModel.addSampleReleaseNote("remote_backups")
}
)
clickPref(
title = DSLSettingsText.from("Add remote donate megaphone"),
onClick = {
@@ -1144,6 +1162,21 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
}
private fun exportQuickstartCredentials() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Export quickstart credentials?")
.setMessage("This will export your account's private keys and credentials to an unencrypted file on disk. This is very dangerous! Only use it with test accounts.")
.setPositiveButton("Export") { _, _ ->
SimpleTask.run({
QuickstartCredentialExporter.export(requireContext())
}) { file ->
Toast.makeText(requireContext(), "Exported to ${file.absolutePath}", Toast.LENGTH_LONG).show()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun promptUserForSentTimestamp() {
val input = EditText(requireContext()).apply {
inputType = android.text.InputType.TYPE_CLASS_NUMBER

View File

@@ -42,7 +42,7 @@ class InternalSettingsRepository(context: Context) {
}
}
fun addSampleReleaseNote() {
fun addSampleReleaseNote(callToAction: String) {
SignalExecutors.UNBOUNDED.execute {
AppDependencies.jobManager.runSynchronously(CreateReleaseChannelJob.create(), 5000)
@@ -52,7 +52,7 @@ class InternalSettingsRepository(context: Context) {
val bodyRangeList = BodyRangeList.Builder()
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
bodyRangeList.addButton("Call to Action Text", "action", body.lastIndex, 0)
bodyRangeList.addButton("Call to Action Text", callToAction, body.lastIndex, 0)
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))

View File

@@ -154,8 +154,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun addSampleReleaseNote() {
repository.addSampleReleaseNote()
fun addSampleReleaseNote(callToAction: String = "action") {
repository.addSampleReleaseNote(callToAction)
}
fun addRemoteDonateMegaphone() {

View File

@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
class InternalBackupPlaygroundFragment : ComposeFragment() {
@@ -230,7 +230,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
.setTitle("Are you sure?")
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
.setPositiveButton("Wipe and restore") { _, _ ->
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
startActivity(RestoreLocalBackupActivity.getIntent(context, finish = false))
}
.show()
},

View File

@@ -30,26 +30,28 @@ class CheckoutNavHostFragment : NavHostFragment() {
get() = requireArguments().getSerializableCompat(ARG_TYPE, InAppPaymentType::class.java)!!
override fun onCreate(savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
navGraph.setStartDestination(
when (inAppPaymentType) {
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
}
)
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
navGraph.setStartDestination(
when (inAppPaymentType) {
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
}
)
val startBundle = when (inAppPaymentType) {
val startBundle = if (savedInstanceState == null) {
when (inAppPaymentType) {
InAppPaymentType.UNKNOWN -> error("Unknown payment type")
InAppPaymentType.ONE_TIME_GIFT, InAppPaymentType.RECURRING_BACKUP -> null
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> DonateToSignalFragmentArgs.Builder(inAppPaymentType).build().toBundle()
}
navController.setGraph(navGraph, startBundle)
} else {
null
}
navController.setGraph(navGraph, startBundle)
super.onCreate(savedInstanceState)
}
}

View File

@@ -310,7 +310,7 @@ class DonateToSignalFragment :
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
isEnabled = state.areFieldsEnabled,
onClick = {
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
if (state.monthlyDonationState.transactionState.isTransactionJobPending && !state.monthlyDonationState.transactionState.isKeepAlive) {
showDonationPendingDialog(state)
} else {
MaterialAlertDialogBuilder(requireContext())

View File

@@ -139,7 +139,8 @@ data class DonateToSignalState(
data class TransactionState(
val isTransactionJobPending: Boolean = false,
val isLevelUpdateInProgress: Boolean = false
val isLevelUpdateInProgress: Boolean = false,
val isKeepAlive: Boolean = false
) {
val isInProgress: Boolean = isTransactionJobPending || isLevelUpdateInProgress
}

View File

@@ -341,7 +341,7 @@ class DonateToSignalViewModel(
state.copy(
monthlyDonationState = state.monthlyDonationState.copy(
nonVerifiedMonthlyDonation = if (jobStatus is DonationRedemptionJobStatus.PendingExternalVerification) jobStatus.nonVerifiedMonthlyDonation else null,
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing)
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing, jobStatus is DonationRedemptionJobStatus.PendingKeepAlive)
)
)
}

View File

@@ -10,7 +10,7 @@ object BankDetailsValidator {
private val EMAIL_REGEX: Regex = ".+@.+\\..+".toRegex()
fun validName(name: String): Boolean {
return name.length >= 2
return name.length >= 3
}
fun validEmail(email: String): Boolean {

View File

@@ -312,7 +312,7 @@ private fun BankTransferDetailsContent(
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
}
},
modifier = Modifier

View File

@@ -266,7 +266,7 @@ private fun IdealTransferDetailsContent(
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
}
},
modifier = Modifier

View File

@@ -34,7 +34,6 @@ object ActiveSubscriptionPreference {
val activeSubscription: ActiveSubscription.Subscription?,
val subscriberRequiresCancel: Boolean,
val onContactSupport: () -> Unit,
val onPendingClick: (FiatMoney) -> Unit,
val onRowClick: (ManageDonationsState.RedemptionState) -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
@@ -79,7 +78,7 @@ object ActiveSubscriptionPreference {
when (model.redemptionState) {
ManageDonationsState.RedemptionState.NONE -> presentRenewalState(model)
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState(model)
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState()
ManageDonationsState.RedemptionState.IN_PROGRESS -> presentInProgressState()
ManageDonationsState.RedemptionState.FAILED -> presentFailureState(model)
ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH -> presentRefreshState()
@@ -102,10 +101,9 @@ object ActiveSubscriptionPreference {
progress.visible = true
}
private fun presentPendingBankTransferState(model: Model) {
private fun presentPendingBankTransferState() {
expiry.text = context.getString(R.string.MySupportPreference__payment_pending)
progress.visible = true
itemView.setOnClickListener { model.onPendingClick(model.price) }
}
private fun presentInProgressState() {

View File

@@ -294,9 +294,6 @@ class ManageDonationsFragment :
subscriberRequiresCancel = state.subscriberRequiresCancel,
onRowClick = {
launcher.launch(InAppPaymentType.RECURRING_DONATION)
},
onPendingClick = {
displayPendingDialog(it)
}
)
)
@@ -317,7 +314,6 @@ class ManageDonationsFragment :
onContactSupport = {},
activeSubscription = null,
subscriberRequiresCancel = state.subscriberRequiresCancel,
onPendingClick = {},
onRowClick = {}
)
)

View File

@@ -35,10 +35,10 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.getParcelableArrayListExtraCompat
import org.signal.core.util.orNull
import org.signal.core.util.requireParcelableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.AvatarPreviewActivity
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -69,9 +69,10 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.R
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
@@ -119,15 +120,16 @@ private const val REQUEST_CODE_ADD_CONTACT = 2
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
class ConversationSettingsFragment : DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
class ConversationSettingsFragment :
DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
private val args: ConversationSettingsFragmentArgs by navArgs()
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
private val colorizer = Colorizer()
private val colorizer = ColorizerV2()
private val blockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
@@ -189,6 +191,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
super.onViewCreated(view, savedInstanceState)
parentFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java)
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
parentFragmentManager.setFragmentResultListener(AboutSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(AboutSheet.RESULT_GROUP_ID, GroupId.V2::class.java)
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
}
@@ -469,9 +481,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
YouAreAlreadyInACallSnackbar.show(requireView())
}
},
onMuteClick = {
onMuteClick = { view ->
if (!state.buttonStripState.isMuted) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
MuteContextMenu.show(view, requireView() as ViewGroup, childFragmentManager, viewLifecycleOwner) { duration ->
viewModel.setMuteUntil(duration)
}
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))
@@ -659,10 +673,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
)
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
onClick = {
startActivity(MediaOverviewActivity.forThread(requireContext(), state.threadId))
startActivityForResult(MediaOverviewActivity.forThread(requireContext(), state.threadId), REQUEST_CODE_RETURN_FROM_MEDIA)
}
)
}
@@ -738,7 +753,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
customPref(
RecipientPreference.Model(
recipient = group,
onClick = {
onRowClick = {
CommunicationActions.startConversation(requireActivity(), group, null)
requireActivity().finish()
}
@@ -786,13 +801,26 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
for (member in groupState.members) {
val canSetMemberLabel = member.member.isSelf && groupState.canSetOwnMemberLabel
val memberLabel = member.getMemberLabel(groupState)
customPref(
RecipientPreference.Model(
recipient = member.member,
isAdmin = member.isAdmin,
memberLabel = member.getMemberLabel(groupState),
memberLabel = memberLabel,
canSetMemberLabel = canSetMemberLabel,
lifecycleOwner = viewLifecycleOwner,
onClick = {
onRowClick = {
if (canSetMemberLabel && memberLabel == null) {
val action = ConversationSettingsFragmentDirections
.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
navController.safeNavigate(action)
} else {
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
}
},
onAvatarClick = {
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
}
)
@@ -825,13 +853,17 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
if (RemoteConfig.sendMemberLabels) {
val canSetMemberLabel = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = canSetMemberLabel,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
navController.safeNavigate(action)
},
onDisabledClicked = {
Snackbar.make(requireView(), R.string.GroupMemberLabel__error_no_edit_permission, Snackbar.LENGTH_SHORT).show()
}
)
}
@@ -1012,7 +1044,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) {
GroupInviteSentDialog.showInvitesSent(requireContext(), viewLifecycleOwner, showGroupInvitesSentDialog.invitesSentTo)
if (showGroupInvitesSentDialog.invitesSentTo.isNotEmpty()) {
GroupInviteSentDialog.show(childFragmentManager, showGroupInvitesSentDialog.invitesSentTo)
}
}
private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) {

View File

@@ -84,7 +84,8 @@ sealed class SpecificSettingsState {
val membershipCountDescription: String = "",
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE,
val isAnnouncementGroup: Boolean = false,
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap()
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap(),
val canSetOwnMemberLabel: Boolean = false
) : SpecificSettingsState() {
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded

View File

@@ -362,6 +362,7 @@ sealed class ConversationSettingsViewModel(
if (groupId.isV2) {
loadMemberLabels(groupId.requireV2(), fullMembers)
loadCanSetMemberLabel(groupId.requireV2())
}
state.copy(
@@ -520,6 +521,17 @@ sealed class ConversationSettingsViewModel(
)
}
}
private fun loadCanSetMemberLabel(v2GroupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
val canSetLabel = MemberLabelRepository.instance.canSetLabel(v2GroupId, Recipient.self())
store.update {
it.copy(
specificSettingsState = it.requireGroupSettingsState().copy(
canSetOwnMemberLabel = canSetLabel
)
)
}
}
}
class Factory(

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import java.util.concurrent.TimeUnit
object MuteContextMenu {
@JvmStatic
fun show(anchor: View, container: ViewGroup, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: (Long) -> Unit): SignalContextMenu {
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
action(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
}
val context = anchor.context
val actionItems = listOf(
ActionItem(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour)) {
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
},
ActionItem(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours)) {
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8))
},
ActionItem(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day)) {
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1))
},
ActionItem(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days)) {
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7))
},
ActionItem(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until)) {
MuteUntilTimePickerBottomSheet.show(fragmentManager)
},
ActionItem(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always)) {
action(Long.MAX_VALUE)
}
)
return SignalContextMenu.Builder(anchor, container)
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(actionItems)
}
}

View File

@@ -0,0 +1,272 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.text.format.DateFormat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
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.DateUtils
import org.thoughtcrime.securesms.util.atMidnight
import org.thoughtcrime.securesms.util.atUTC
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
import java.util.Locale
class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
companion object {
const val REQUEST_KEY = "mute_until_result"
const val RESULT_TIMESTAMP = "timestamp"
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = MuteUntilTimePickerBottomSheet()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
val context = LocalContext.current
val now = remember { LocalDateTime.now() }
val defaultDateTime = remember {
if (now.hour < 17) {
now.withHour(17).withMinute(0).withSecond(0).withNano(0)
} else {
val nextMorning = if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY || now.dayOfWeek == DayOfWeek.SUNDAY) {
now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
} else {
now.plusDays(1)
}
nextMorning.withHour(8).withMinute(0).withSecond(0).withNano(0)
}
}
var selectedDate by remember { mutableLongStateOf(defaultDateTime.toMillis()) }
var selectedHour by remember { mutableIntStateOf(defaultDateTime.hour) }
var selectedMinute by remember { mutableIntStateOf(defaultDateTime.minute) }
val dateText = remember(selectedDate) {
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), selectedDate)
}
val timeText = remember(selectedHour, selectedMinute) {
LocalTime.of(selectedHour, selectedMinute).formatHours(context)
}
val zonedDateTime = remember { ZonedDateTime.now() }
val timezoneDisclaimer = remember {
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
context.getString(
R.string.MuteUntilTimePickerBottomSheet__timezone_disclaimer,
zoneOffsetFormatter.format(zonedDateTime),
zoneNameFormatter.format(zonedDateTime)
)
}
MuteUntilSheetContent(
dateText = dateText,
timeText = timeText,
timezoneDisclaimer = timezoneDisclaimer,
onDateClick = {
val local = LocalDateTime.now().atMidnight().atUTC().toMillis()
val datePicker = MaterialDatePicker.Builder.datePicker()
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_date_title))
.setSelection(selectedDate)
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
.build()
datePicker.addOnDismissListener {
datePicker.clearOnDismissListeners()
datePicker.clearOnPositiveButtonClickListeners()
}
datePicker.addOnPositiveButtonClickListener {
selectedDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
}
datePicker.show(childFragmentManager, "DATE_PICKER")
},
onTimeClick = {
val timeFormat = if (DateFormat.is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
val timePicker = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(selectedHour)
.setMinute(selectedMinute)
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_time_title))
.build()
timePicker.addOnDismissListener {
timePicker.clearOnDismissListeners()
timePicker.clearOnPositiveButtonClickListeners()
}
timePicker.addOnPositiveButtonClickListener {
selectedHour = timePicker.hour
selectedMinute = timePicker.minute
}
timePicker.show(childFragmentManager, "TIME_PICKER")
},
onMuteClick = {
val timestamp = selectedDate.toLocalDateTime()
.withHour(selectedHour)
.withMinute(selectedMinute)
.withSecond(0)
.withNano(0)
.toMillis()
if (timestamp > System.currentTimeMillis()) {
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_TIMESTAMP to timestamp))
dismissAllowingStateLoss()
}
}
)
}
}
@Composable
private fun MuteUntilSheetContent(
dateText: String,
timeText: String,
timezoneDisclaimer: String,
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
onMuteClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
Text(
text = stringResource(R.string.MuteUntilTimePickerBottomSheet__dialog_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 18.dp, bottom = 24.dp)
)
Text(
text = timezoneDisclaimer,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier
.padding(horizontal = 56.dp)
.align(Alignment.Start)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onDateClick)
) {
Text(
text = dateText,
style = MaterialTheme.typography.bodyLarge
)
Icon(
painter = painterResource(R.drawable.ic_expand_down_24),
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp)
.size(24.dp)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onTimeClick)
) {
Text(
text = timeText,
style = MaterialTheme.typography.bodyLarge
)
Icon(
painter = painterResource(R.drawable.ic_expand_down_24),
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp)
.size(24.dp)
)
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 24.dp)
) {
Buttons.MediumTonal(
onClick = onMuteClick
) {
Text(stringResource(R.string.MuteUntilTimePickerBottomSheet__mute_notifications))
}
}
}
}
@DayNightPreviews
@Composable
private fun MuteUntilSheetContentPreview() {
Previews.BottomSheetContentPreview {
MuteUntilSheetContent(
dateText = "Today",
timeText = "5:00 PM",
timezoneDisclaimer = "All times in (GMT-05:00) Eastern Standard Time",
onDateClick = {},
onTimeClick = {},
onMuteClick = {}
)
}
}

View File

@@ -30,7 +30,7 @@ object ButtonStripPreference {
val onMessageClick: () -> Unit = {},
val onVideoClick: () -> Unit = {},
val onAudioClick: () -> Unit = {},
val onMuteClick: () -> Unit = {},
val onMuteClick: (View) -> Unit = {},
val onSearchClick: () -> Unit = {}
) : PreferenceModel<Model>() {
override fun areContentsTheSame(newItem: Model): Boolean {
@@ -97,7 +97,7 @@ object ButtonStripPreference {
message.setOnClickListener { model.onMessageClick() }
videoCall.setOnClickListener { model.onVideoClick() }
audioCall.setOnClickListener { model.onAudioClick() }
mute.setOnClickListener { model.onMuteClick() }
mute.setOnClickListener { model.onMuteClick(it) }
search.setOnClickListener { model.onSearchClick() }
addToStory.setOnClickListener { model.onAddToStoryClick() }
}

View File

@@ -3,8 +3,6 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.text.SpannableStringBuilder
import android.view.View
import android.widget.TextView
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
@@ -36,8 +34,10 @@ object RecipientPreference {
val recipient: Recipient,
val isAdmin: Boolean = false,
val memberLabel: StyledMemberLabel? = null,
val canSetMemberLabel: Boolean = false,
val lifecycleOwner: LifecycleOwner? = null,
val onClick: (() -> Unit)? = null
val onRowClick: (() -> Unit)? = null,
val onAvatarClick: (() -> Unit)? = null
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id
@@ -47,7 +47,8 @@ object RecipientPreference {
return super.areContentsTheSame(newItem) &&
recipient.hasSameContent(newItem.recipient) &&
isAdmin == newItem.isAdmin &&
memberLabel == newItem.memberLabel
memberLabel == newItem.memberLabel &&
canSetMemberLabel == newItem.canSetMemberLabel
}
}
@@ -56,28 +57,36 @@ object RecipientPreference {
private val name: TextView = itemView.findViewById(R.id.recipient_name)
private val about: TextView? = itemView.findViewById(R.id.recipient_about)
private val memberLabelView: MemberLabelPillView? = itemView.findViewById(R.id.recipient_member_label)
private val addMemberLabelView: TextView? = itemView.findViewById(R.id.add_member_label)
private val admin: View? = itemView.findViewById(R.id.admin)
private val badge: BadgeImageView = itemView.findViewById(R.id.recipient_badge)
private var recipient: Recipient? = null
private var canSetMemberLabel: Boolean = false
private val recipientObserver = Observer<Recipient> { recipient ->
onRecipientChanged(recipient)
onRecipientChanged(recipient = recipient, memberLabel = null, canSetMemberLabel = canSetMemberLabel)
}
override fun bind(model: Model) {
if (model.onClick != null) {
itemView.setOnClickListener { model.onClick.invoke() }
if (model.onRowClick != null) {
itemView.setOnClickListener { model.onRowClick.invoke() }
} else {
itemView.setOnClickListener(null)
}
if (model.onAvatarClick != null) {
avatar.setOnClickListener { model.onAvatarClick.invoke() }
} else {
avatar.setOnClickListener(null)
}
canSetMemberLabel = model.canSetMemberLabel
if (model.lifecycleOwner != null) {
observeRecipient(model.lifecycleOwner, model.recipient)
model.memberLabel?.let(::showMemberLabel)
} else {
onRecipientChanged(model.recipient, model.memberLabel)
}
onRecipientChanged(model.recipient, model.memberLabel, model.canSetMemberLabel)
admin?.visible = model.isAdmin
}
@@ -86,7 +95,7 @@ object RecipientPreference {
unbind()
}
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null) {
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null, canSetMemberLabel: Boolean = false) {
avatar.setRecipient(recipient)
badge.setBadgeFromRecipient(recipient)
name.text = if (recipient.isSelf) {
@@ -104,17 +113,17 @@ object RecipientPreference {
}
}
val aboutText = recipient.combinedAboutAndEmoji
when {
memberLabel != null -> showMemberLabel(memberLabel)
!recipient.combinedAboutAndEmoji.isNullOrEmpty() -> {
about?.text = recipient.combinedAboutAndEmoji
about?.visible = true
memberLabelView?.visible = false
}
recipient.isSelf && canSetMemberLabel -> showAddMemberLabel()
!aboutText.isNullOrBlank() -> showAbout(aboutText)
else -> {
memberLabelView?.visible = false
addMemberLabelView?.visible = false
about?.visible = false
}
}
@@ -122,18 +131,29 @@ object RecipientPreference {
private fun showMemberLabel(styledLabel: StyledMemberLabel) {
memberLabelView?.apply {
style = MemberLabelPillView.Style(
horizontalPadding = 8.dp,
verticalPadding = 2.dp,
textStyle = { MaterialTheme.typography.labelSmall }
)
style = MemberLabelPillView.Style.Compact
setLabel(styledLabel.label, styledLabel.tintColor)
visible = true
}
addMemberLabelView?.visible = false
about?.visible = false
}
private fun showAddMemberLabel() {
addMemberLabelView?.visible = true
memberLabelView?.visible = false
about?.visible = false
}
private fun showAbout(text: String) {
about?.text = text
about?.visible = true
memberLabelView?.visible = false
addMemberLabelView?.visible = false
}
private fun observeRecipient(lifecycleOwner: LifecycleOwner?, recipient: Recipient?) {
this.recipient?.live()?.liveData?.removeObserver(recipientObserver)

View File

@@ -16,9 +16,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
) {
class SoundsAndNotificationsSettingsFragment :
DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
) {
private val mentionLabels: Array<String> by lazy {
resources.getStringArray(R.array.SoundsAndNotificationsSettingsFragment__mention_labels)
@@ -66,7 +67,7 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
summary = DSLSettingsText.from(muteSummary),
onClick = {
if (state.muteUntil <= 0) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner, viewModel::setMuteUntil)
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(muteSummary)

View File

@@ -197,6 +197,7 @@ data class CallParticipantsState(
companion object {
const val SMALL_GROUP_MAX = 6
const val PRE_JOIN_MUTE_THRESHOLD = 8
@JvmField
val MAX_OUTGOING_GROUP_RING_DURATION = TimeUnit.MINUTES.toMillis(1)

View File

@@ -7,6 +7,7 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
@@ -28,6 +29,15 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
companion object {
const val TAG = "WebRtcAudioPicker31"
private fun WebRtcAudioOutput.toSignalAudioDevice(): SignalAudioManager.AudioDevice {
return when (this) {
WebRtcAudioOutput.HANDSET -> SignalAudioManager.AudioDevice.EARPIECE
WebRtcAudioOutput.SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
WebRtcAudioOutput.BLUETOOTH_HEADSET -> SignalAudioManager.AudioDevice.BLUETOOTH
WebRtcAudioOutput.WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
}
}
}
fun showPicker(fragmentActivity: FragmentActivity, threshold: Int, onDismiss: (DialogInterface) -> Unit): DialogInterface? {
@@ -60,20 +70,20 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
val am = AppDependencies.androidCallAudioManager
if (am.availableCommunicationDevices.isEmpty()) {
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
LaunchedEffect(Unit) {
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
stateUpdater.hidePicker()
}
return
}
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
val currentDeviceId = am.communicationDevice?.id ?: -1
if (devices.size < threshold) {
Log.d(TAG, "Only found $devices devices, not showing picker.")
if (devices.isEmpty()) return
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
if (index == -1) return
onAudioDeviceSelected(devices[(index + 1) % devices.size])
LaunchedEffect(Unit) {
Log.d(TAG, "Only found $devices devices, not showing picker.")
cycleToNextDevice()
}
return
} else {
Log.d(TAG, "Found $devices devices, showing picker.")
@@ -124,6 +134,37 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
}
}
/**
* Cycles to the next audio device without showing a picker.
* Uses the system device list to resolve the actual device ID, falling back to
* type-based lookup from app-tracked state when the current communication device is unknown.
*/
fun cycleToNextDevice() {
val am = AppDependencies.androidCallAudioManager
val devices: List<AudioOutputOption> = am.availableCommunicationDevices
.map { AudioOutputOption("", AudioDeviceMapping.fromPlatformType(it.type), it.id) }
.distinctBy { it.deviceType.name }
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
if (devices.isEmpty()) {
Log.w(TAG, "cycleToNextDevice: no available communication devices")
return
}
val currentDeviceId = am.communicationDevice?.id ?: -1
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
if (index != -1) {
onAudioDeviceSelected(devices[(index + 1) % devices.size])
} else {
val nextOutput = outputState.peekNext()
val targetDeviceType = nextOutput.toSignalAudioDevice()
val targetDevice = devices.firstOrNull { it.deviceType == targetDeviceType } ?: devices.first()
Log.d(TAG, "cycleToNextDevice: communicationDevice unknown, selecting ${targetDevice.deviceType} by type")
onAudioDeviceSelected(targetDevice)
}
}
private fun AudioOutputOption.toWebRtcAudioOutput(): WebRtcAudioOutput {
return when (this.deviceType) {
SignalAudioManager.AudioDevice.WIRED_HEADSET -> WebRtcAudioOutput.WIRED_HEADSET

View File

@@ -34,8 +34,6 @@ class ControlsAndInfoViewModel(
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
val epochSnapshot: ByteArray?
get() = state.value.callLink?.credentials?.epochBytes
fun setRecipient(recipient: Recipient) {
if (recipient.isCallLink && callRecipientId != recipient.id) {

View File

@@ -185,11 +185,13 @@ class AudioOutputPickerController(
val isLegacy = Build.VERSION.SDK_INT < 31
if (!willDisplayPicker) {
if (isLegacy) {
LaunchedEffect(Unit) {
displaySheet = false
onSelectedDeviceChanged(WebRtcAudioDevice(outputState.peekNext(), null))
} else {
newApiController!!.Picker(threshold = SHOW_PICKER_THRESHOLD)
if (isLegacy) {
onSelectedDeviceChanged(WebRtcAudioDevice(outputState.peekNext(), null))
} else {
newApiController!!.cycleToNextDevice()
}
}
return
}

View File

@@ -216,6 +216,16 @@ data class CallControlsState(
val startCallButtonText: Int = R.string.WebRtcCallView__start_call,
val displayEndCallButton: Boolean = false
) {
val hasAnyControls: Boolean
get() = displayAudioOutputToggle ||
displayVideoToggle ||
displayMicToggle ||
displayGroupRingingToggle ||
displayAdditionalActions ||
displayStartCallButton ||
displayEndCallButton
companion object {
/**
* Presentation-level method to build out the controls state from legacy objects.

View File

@@ -16,11 +16,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
@@ -36,17 +36,30 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Enum identifying each slot in the BlurrableContentLayer.
* Used as subcomposition keys to ensure each slot is only composed once.
* Mutable holder for bar dimensions, used to pass measurement results from
* BlurrableContentLayer to PipLayer during the same Layout measurement pass
* without requiring SubcomposeLayout.
*/
private enum class BlurrableContentSlot {
BARS,
GRID,
REACTIONS,
OVERFLOW,
AUDIO_INDICATOR
private class BarDimensions {
var heightPx: Int = 0
var widthPx: Int = 0
}
/**
* Arranges call screen content in coordinated layers so the local PiP can avoid centered bars.
*
* @param callGridSlot Main call grid content.
* @param pictureInPictureSlot Local participant PiP content.
* @param reactionsSlot Reactions overlay content.
* @param raiseHandSlot Slot for the raised-hand bar.
* @param callLinkBarSlot Slot for the call-link bar.
* @param callOverflowSlot Overflow participants strip.
* @param audioIndicatorSlot Participant audio indicator content.
* @param bottomInset Bottom inset used to keep content clear of anchored UI.
* @param bottomSheetWidth Maximum width of centered bottom content.
* @param localRenderState Current local renderer mode.
* @param modifier Modifier applied to the root layout.
*/
@Composable
fun CallElementsLayout(
callGridSlot: @Composable () -> Unit,
@@ -83,58 +96,54 @@ fun CallElementsLayout(
bottomSheetWidth.roundToPx()
}
SubcomposeLayout(modifier = modifier) { constraints ->
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val barDimensions = remember { BarDimensions() }
// Holder to capture measurements from BlurrableContentLayer
var measuredBarsHeightPx = 0
var measuredBarsWidthPx = 0
// Subcompose and measure the blurrable layer first - it will measure all content internally
// and report back the bars dimensions via the onMeasured callback
val blurrableLayerPlaceable = subcompose("blurrable") {
BlurrableContentLayer(
isFocused = isFocused,
isPortrait = isPortrait,
bottomInsetPx = bottomInsetPx,
bottomSheetWidthPx = bottomSheetWidthPx,
barsSlot = { Bars() },
callGridSlot = callGridSlot,
reactionsSlot = reactionsSlot,
callOverflowSlot = callOverflowSlot,
audioIndicatorSlot = audioIndicatorSlot,
onMeasured = { barsHeight, barsWidth ->
measuredBarsHeightPx = barsHeight
measuredBarsWidthPx = barsWidth
}
)
}.map { it.measure(constraints) }
// Use the wider of bars or bottom sheet for space calculation
val centeredContentWidthPx = maxOf(measuredBarsWidthPx, bottomSheetWidthPx)
val pipLayerPlaceable = subcompose("pip") {
PipLayer(
pictureInPictureSlot = pictureInPictureSlot,
localRenderState = localRenderState,
bottomInsetPx = bottomInsetPx,
barsHeightPx = measuredBarsHeightPx,
pipSizePx = pipSizePx,
centeredContentWidthPx = centeredContentWidthPx
)
}.map { it.measure(constraints) }
Layout(
contents = listOf(
{
BlurrableContentLayer(
isFocused = isFocused,
isPortrait = isPortrait,
bottomInsetPx = bottomInsetPx,
bottomSheetWidthPx = bottomSheetWidthPx,
barsSlot = { Bars() },
callGridSlot = callGridSlot,
reactionsSlot = reactionsSlot,
callOverflowSlot = callOverflowSlot,
audioIndicatorSlot = audioIndicatorSlot,
onMeasured = { barsHeight, barsWidth ->
barDimensions.heightPx = barsHeight
barDimensions.widthPx = barsWidth
}
)
},
{
PipLayer(
pictureInPictureSlot = pictureInPictureSlot,
localRenderState = localRenderState,
bottomInsetPx = bottomInsetPx,
barDimensions = barDimensions,
pipSizePx = pipSizePx,
bottomSheetWidthPx = bottomSheetWidthPx
)
}
),
modifier = modifier
) { (blurrableMeasurables, pipMeasurables), constraints ->
val blurrablePlaceables = blurrableMeasurables.map { it.measure(constraints) }
val pipPlaceables = pipMeasurables.map { it.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
blurrableLayerPlaceable.forEach { it.place(0, 0) }
pipLayerPlaceable.forEach { it.place(0, 0) }
blurrablePlaceables.forEach { it.place(0, 0) }
pipPlaceables.forEach { it.place(0, 0) }
}
}
}
/**
* A layer that contains content which can be blurred when the local participant video is focused.
* All slots are subcomposed here ONCE to avoid duplicate subcomposition that would cause
* IllegalArgumentException when slots contain SubcomposeLayout (like BoxWithConstraints).
* Uses a single multi-content Layout pass to avoid re-subcomposing slots that contain
* SubcomposeLayout (like BoxWithConstraints), which can trigger duplicate key crashes.
*
* @param onMeasured Callback invoked during measurement with (barsHeight, barsWidth) to report
* dimensions needed by the parent layout for PipLayer positioning.
@@ -156,17 +165,26 @@ private fun BlurrableContentLayer(
isBlurred = isFocused,
modifier = Modifier.fillMaxSize()
) {
SubcomposeLayout(modifier = Modifier.fillMaxSize()) { constraints ->
Layout(
contents = listOf(
{ callOverflowSlot() },
{ callGridSlot() },
{ barsSlot() },
{ reactionsSlot() },
{ audioIndicatorSlot() }
),
modifier = Modifier.fillMaxSize()
) { measurables, constraints ->
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val overflowPlaceables = subcompose(BlurrableContentSlot.OVERFLOW, callOverflowSlot)
.map { it.measure(looseConstraints) }
val (overflowMeasurables, gridMeasurables, barsMeasurables, reactionsMeasurables, audioIndicatorMeasurables) = measurables
val overflowPlaceables = overflowMeasurables.map { it.measure(looseConstraints) }
val constrainedHeightOffset = if (isPortrait) overflowPlaceables.maxOfOrNull { it.height } ?: 0 else 0
val constrainedWidthOffset = if (isPortrait) 0 else overflowPlaceables.maxOfOrNull { it.width } ?: 0
val nonOverflowConstraints = looseConstraints.offset(horizontal = -constrainedWidthOffset, vertical = -constrainedHeightOffset)
val gridPlaceables = subcompose(BlurrableContentSlot.GRID, callGridSlot)
.map { it.measure(nonOverflowConstraints) }
val gridPlaceables = gridMeasurables.map { it.measure(nonOverflowConstraints) }
val barConstraints = if (bottomInsetPx > constrainedHeightOffset) {
looseConstraints.offset(-constrainedWidthOffset, -bottomInsetPx)
@@ -174,31 +192,27 @@ private fun BlurrableContentLayer(
nonOverflowConstraints
}
// Cap bars width to sheet max width (bars can be narrower if content doesn't fill)
val barsMaxWidth = minOf(barConstraints.maxWidth, bottomSheetWidthPx)
val barsConstrainedToSheet = barConstraints.copy(maxWidth = barsMaxWidth)
val barsPlaceables = subcompose(BlurrableContentSlot.BARS, barsSlot)
.map { it.measure(barsConstrainedToSheet) }
val barsPlaceables = barsMeasurables.map { it.measure(barsConstrainedToSheet) }
val barsHeightPx = barsPlaceables.sumOf { it.height }
val barsWidthPx = barsPlaceables.maxOfOrNull { it.width } ?: 0
// Report measurements to parent for PipLayer positioning
onMeasured(barsHeightPx, barsWidthPx)
val reactionsConstraints = barConstraints.offset(vertical = -barsHeightPx)
val reactionsPlaceables = subcompose(BlurrableContentSlot.REACTIONS, reactionsSlot)
.map { it.measure(reactionsConstraints) }
val reactionsPlaceables = reactionsMeasurables.map { it.measure(reactionsConstraints) }
val audioIndicatorPlaceables = subcompose(BlurrableContentSlot.AUDIO_INDICATOR, audioIndicatorSlot)
.map { it.measure(looseConstraints) }
val audioIndicatorPlaceables = audioIndicatorMeasurables.map { it.measure(looseConstraints) }
layout(looseConstraints.maxWidth, looseConstraints.maxHeight) {
overflowPlaceables.forEach {
if (isPortrait) {
if (isPortrait) {
overflowPlaceables.forEach {
it.place(0, looseConstraints.maxHeight - it.height)
} else {
}
} else {
overflowPlaceables.forEach {
it.place(looseConstraints.maxWidth - it.width, 0)
}
}
@@ -236,9 +250,9 @@ private fun PipLayer(
pictureInPictureSlot: @Composable () -> Unit,
localRenderState: WebRtcLocalRenderState,
bottomInsetPx: Int,
barsHeightPx: Int,
barDimensions: BarDimensions,
pipSizePx: Size,
centeredContentWidthPx: Int
bottomSheetWidthPx: Int
) {
Layout(
content = pictureInPictureSlot,
@@ -246,13 +260,14 @@ private fun PipLayer(
) { measurables, constraints ->
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val centeredContentWidthPx = maxOf(barDimensions.widthPx, bottomSheetWidthPx)
val pictureInPictureConstraints: Constraints = when (localRenderState) {
WebRtcLocalRenderState.GONE, WebRtcLocalRenderState.SMALLER_RECTANGLE, WebRtcLocalRenderState.LARGE, WebRtcLocalRenderState.LARGE_NO_VIDEO, WebRtcLocalRenderState.FOCUSED -> constraints
WebRtcLocalRenderState.SMALL_RECTANGLE, WebRtcLocalRenderState.EXPANDED -> {
// Check if there's enough space on either side of the centered content (bars/sheet)
val spaceOnEachSide = (looseConstraints.maxWidth - centeredContentWidthPx) / 2
val shouldOffset = centeredContentWidthPx > 0 && spaceOnEachSide < pipSizePx.width
val offsetAmount = bottomInsetPx + barsHeightPx
val offsetAmount = bottomInsetPx + barDimensions.heightPx
looseConstraints.offset(vertical = if (shouldOffset) -offsetAmount else 0)
}
}

View File

@@ -23,6 +23,7 @@ sealed interface CallEvent {
data class ShowGroupCallSafetyNumberChange(val identityRecords: List<IdentityRecord>) : CallEvent
data object SwitchToSpeaker : CallEvent
data object ShowSwipeToSpeakerHint : CallEvent
data object ShowLargeGroupAutoMuteToast : CallEvent
data class ShowRemoteMuteToast(private val muted: Recipient, private val mutedBy: Recipient) : CallEvent {
fun getDescription(context: Context): String {
return if (muted.isSelf && mutedBy.isSelf) {

View File

@@ -530,6 +530,9 @@ fun <T> CallGrid(
val hasExistingItems = knownKeys.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)
@@ -569,7 +572,9 @@ fun <T> CallGrid(
launch { scaleAnimatables[key]?.animateTo(CallGridDefaults.EXIT_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
}
exitingItems = exitingItems.filterNot { it.key == key }
removeAnimationState(key)
if (key !in knownKeys) {
removeAnimationState(key)
}
}
} else {
removeAnimationState(key)

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