Compare commits

...

298 Commits

Author SHA1 Message Date
Cody Henthorne c5efb2ce6c Bump version to 8.15.1 2026-06-11 14:20:28 -04:00
Cody Henthorne c7b5738787 Update baseline profile. 2026-06-11 14:13:07 -04:00
Cody Henthorne df88b7fe35 Update translations and other static files. 2026-06-11 14:03:42 -04:00
Cody Henthorne 46213b38e7 Exclude quote attachments from sticker detection during backup export. 2026-06-11 12:26:41 -04:00
Alex Hart f8b53378c6 Remove touch interceptor. 2026-06-11 13:16:23 -03:00
Greyson Parrelli bbb09eb7a0 Debounce search queries on conversation list. 2026-06-10 23:54:55 -04:00
Cody Henthorne 52ceb4062d Reject group call ring updates from senders who are not current group members. 2026-06-10 16:00:59 -04:00
Cody Henthorne f7eaa1cb51 Fix outgoing group calls showing as incoming when someone joins. 2026-06-10 16:00:59 -04:00
Cody Henthorne c62c15c810 Refresh conversation options menu when message request state changes. 2026-06-10 16:00:59 -04:00
Cody Henthorne 2389279cfd Prevent exported settings activity from honoring caller-supplied navigation graph. 2026-06-10 16:00:59 -04:00
Cody Henthorne 3e92fca26d Bump version to 8.15.0 2026-06-10 15:51:38 -04:00
Cody Henthorne 526928ce0a Update baseline profile. 2026-06-10 15:40:12 -04:00
Cody Henthorne 2ea2f561ae Update translations and other static files. 2026-06-10 15:29:25 -04:00
Cody Henthorne c88f048049 Ensure unregistered contacts update storage service if previously registered. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 204a233235 Some improvements to apkdiff. 2026-06-10 15:20:00 -04:00
BarbossHack 2e4abd8ed3 Switch to semantic resources.arsc comparison in apkdiff.
Closes signalapp/Signal-Android#14828
2026-06-10 15:20:00 -04:00
Greyson Parrelli a2a0b11c98 Improve validation on launcher alias intents. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 96f893652b Improve receipt message validations. 2026-06-10 15:20:00 -04:00
Greyson Parrelli d4924d2a13 Ignore START_ROUTE in exported settings activity when launched by another app. 2026-06-10 15:20:00 -04:00
Greyson Parrelli fa6b512cfc Improve link preview validations. 2026-06-10 15:20:00 -04:00
Alex Hart d447af36ba Surface retryable failure for slow donations config load. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 41260f37c9 Fix bug where background connection would drop in forceWebsocket mode.
Relates to #14793
2026-06-10 15:20:00 -04:00
Greyson Parrelli 8950f7f7f9 Simplify media session permission handling. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 14916d068f Allow all group call participants to remote mute, not just admins. 2026-06-10 15:20:00 -04:00
emir-signal 33022baaa2 Update to RingRTC v2.69.3
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-06-10 15:20:00 -04:00
Alex Hart 4cdd1f70ac Fix contact search list flickering on query change. 2026-06-10 15:20:00 -04:00
Alex Hart 9e3ee16e65 Fix dead toolbar navigation arrow in ShareActivity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-10 15:20:00 -04:00
Michelle Tang bf912e14d9 Turn on KT. 2026-06-10 15:20:00 -04:00
Cody Henthorne 56f1a9e0ec Clear SVR auth tokens when disabling the PIN. 2026-06-10 15:20:00 -04:00
Cody Henthorne e468156c4c Verify multiple APNG lengths to prevent bad input from crashing. 2026-06-10 15:20:00 -04:00
Alex Hart 029b91066f Add warnings for phishing. 2026-06-10 15:20:00 -04:00
Alex Hart 5909a1b92a Migrate MultiselectForward to Jetpack Compose. 2026-06-09 17:21:48 -04:00
Cody Henthorne 9478cdf049 Reset SAS for device transfer on reconnect and hard abort if disconnected during transfer. 2026-06-09 17:21:48 -04:00
Michelle Tang 6d260ab63d Update announcement checks. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4bc11fcf0d Rotate IndividuaSendJobV2 remote config. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 33619fe463 Fix IndividualSendJobV2 edit message timestamps. 2026-06-09 17:21:48 -04:00
Cody Henthorne 2b17868797 Skip create remote key copy if duplicate is same attachment. 2026-06-09 17:21:48 -04:00
Alex Hart 582a464a52 Fix screen share video being center-cropped. 2026-06-09 17:21:48 -04:00
Cody Henthorne 812a858761 Reject attachments with unrecognized CDN numbers instead of crashing. 2026-06-09 17:21:48 -04:00
Alex Hart 6ea96795cb Fix detail pane state getting stuck after cancelled predictive back gesture.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:48 -04:00
Michelle Tang 70ab0baa3c Fix broken calling test. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5041057bab Update shouldIngore for edit and decryption error messages. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 6210d1b397 Improve proxy configuration. 2026-06-09 17:21:48 -04:00
Greyson Parrelli f1accae295 Fix bidi balancing character. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5f6d20453c Improve incoming payment validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli d33385d1b2 Improve group story reply and reaction validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 3d05bc3471 Fix rules with group story replies and timers. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0514a5c6c8 Add a hard cap of stickers per pack manifest. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 798ba11e62 Validate style body ranges have a start and length to prevent message processing crash. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 848a61787b Improve device transfer wifi validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli ccfbb27695 Improve body range validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 26b1d3a0f8 Improve username link storage service validation. 2026-06-09 17:21:48 -04:00
Greyson Parrelli c5785c086e Strip the full set of Unicode bidi controls from attachment filenames. 2026-06-09 17:21:48 -04:00
Cody Henthorne 6aeb145024 Require Play Services send permission for SMS retriever broadcasts. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 7e8c6228d8 Improve CDN mismatch reconciliation query. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4c08b94b88 Reduce Recipient usage in group-related jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 135bc6e560 Convert a batch of androidTest db tests to unit tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0ebeb5aa92 Restrict S3 downloads to ReleaseChannel. 2026-06-09 17:21:48 -04:00
Greyson Parrelli aaa7a18190 Remove Robolectric from some tests with light mocking. 2026-06-09 17:21:48 -04:00
Greyson Parrelli bfc1c4ebfa Removed unnecessary Robolectric annotation from some tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli b4cf59f9c2 Reduce Recipient usage in some jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 15e7b30fa1 Add tests for IndividualSendJobV2. 2026-06-09 17:21:47 -04:00
Cody Henthorne a5359e05a3 Convert TransferControlView rendering to compose. 2026-06-09 17:21:47 -04:00
Michelle Tang 73557ae72a Add jitter to KT weekly check. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2505049e39 Allow WebSocketDrainer to run longer if network returns while waiting. 2026-06-09 17:21:47 -04:00
Cody Henthorne a4ae6581ef Improve website apk update flow. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6399a2d899 Avoid taking a transaction in RemappedRecordTables. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 56af57db9e Un-export FCM service. 2026-06-09 17:21:47 -04:00
Greyson Parrelli ddf0de52b1 Log transaction waits as separate issues. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 925e2c1705 Fix potential recursive call. 2026-06-09 17:21:47 -04:00
Greyson Parrelli cb719dff1a Reduce logging verbosity. 2026-06-09 17:21:47 -04:00
Cody Henthorne f2fd3e63c8 Prevent accepted group message request from resetting when restored from storage. 2026-06-09 17:21:47 -04:00
Michelle Tang 8560ab0515 Expire calls from call log. 2026-06-09 17:21:47 -04:00
Michelle Tang d586eff80b Expire group calls. 2026-06-09 17:21:47 -04:00
Alex Hart 27ddd62d7a Keep user in gift flow after payment error instead of finishing the activity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 38f31528ff Fix mob stringification in backups.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Greyson Parrelli 335fcd72f3 Don't flag regV5 PIN entry as a password. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 118231a328 Attempt to restore AccountRecord in regV5. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 754dd15c94 Add device transfer flow to regV5. 2026-06-09 17:21:47 -04:00
Cody Henthorne 566c2d5838 Always scroll to the top of the conversation header when in message request state. 2026-06-09 17:21:47 -04:00
Greyson Parrelli eae894152c Add a new ci task with faster lint. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 53c4069c64 Track perf issues in a table, add internal viewer. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 65082893db Add index for scheduled message queries. 2026-06-09 17:21:47 -04:00
ArseniiS 595364b522 Convert HelpFragment to Compose.
Co-authored-by: Alex Hart <alex@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 7cce504f16 Fix unread divider placement and scroll-to-unread on conversation open. 2026-06-09 17:21:47 -04:00
Alex Hart 337afb11db Add screen size check for link and sync. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6027d58fb5 Fix various lint issues. 2026-06-09 17:21:47 -04:00
jeffrey-signal 132eaa5c70 Adjust tablet preview dimensions to better align with the current WindowBreakpoint config. 2026-06-09 17:21:47 -04:00
jeffrey-signal ba3e15ea6d RegV5 text style and padding fixes. 2026-06-09 17:21:47 -04:00
jeffrey-signal ceee5f714d Adjust RegV5 window insets so screens draw under system bars and handle display cutouts. 2026-06-09 17:21:47 -04:00
jeffrey-signal 4c942f39b0 Elevate RegistrationScaffold footer surface when content scrolls underneath. 2026-06-09 17:21:47 -04:00
jeffrey-signal f3a5bba3f2 Enable TwoPaneRegistrationScaffold panes to scroll independently. 2026-06-09 17:21:47 -04:00
jeffrey-signal 8af7606e3f Use TopAppBar on all RegV5 screens. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2adf84a895 Bump version to 8.14.3 2026-06-09 17:06:45 -04:00
Cody Henthorne 30ed0aa11a Update baseline profile. 2026-06-09 17:01:17 -04:00
Cody Henthorne ec9ae9e3b1 Update translations and other static files. 2026-06-09 16:48:06 -04:00
Cody Henthorne 6a23896077 Fix view state restore crash from LinkPreviewView sharing an id with its ViewStub. 2026-06-09 16:22:57 -04:00
Greyson Parrelli f5a1d79eb5 Ensure we don't run the SVR migration for unregistered users. 2026-06-09 16:20:28 -04:00
Alex Hart 4f0f0938d8 Bump version to 8.14.2 2026-06-05 16:38:39 -03:00
Alex Hart 0136971963 Update translations and other static files. 2026-06-05 16:25:51 -03:00
Michelle Tang f810d731dd Turn off KT. 2026-06-05 14:32:07 -04:00
Cody Henthorne 7c7c364fef Fix sending quoted voice notes in 1:1 chats via IndividualSendJobV2.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-05 14:28:16 -04:00
Alex Hart aa9591211b Bump version to 8.14.1 2026-06-04 16:21:46 -03:00
Alex Hart bbd48547e5 Update translations and other static files. 2026-06-04 16:08:30 -03:00
Cody Henthorne 757b521744 Add additional logging around message request interactions. 2026-06-04 13:59:47 -04:00
Cody Henthorne a6311c87c1 Cleanup bad notified state in background instead of during db migration. 2026-06-04 13:55:38 -04:00
Cody Henthorne 045bd9287b Fix incorrect quote and link preview compose layout. 2026-06-04 11:30:37 -04:00
Cody Henthorne f1a72dd01a Use CDN number instead of parsing identifier for attachment remote id. 2026-06-04 11:22:14 -04:00
Cody Henthorne af4d0a0ef0 Fix illegal session state crashes in receipt send flows. 2026-06-04 11:21:46 -04:00
Greyson Parrelli 7dcaa933f2 Rotate IndividuaSendJobV2 remote config. 2026-06-04 00:36:04 -04:00
Greyson Parrelli 2c88945e6b Fix sending messages to self when your session is deleted. 2026-06-04 00:35:40 -04:00
Greyson Parrelli f9b9ce6c14 Fix character swapping during backup restore. 2026-06-03 23:57:04 -04:00
Michelle Tang 1d8fbad17e Add additional unauthorized KT check. 2026-06-03 17:14:40 -04:00
Alex Hart 6872a14378 Bump version to 8.14.0 2026-06-03 15:18:58 -03:00
Alex Hart 3a1eb4bd88 Update translations and other static files. 2026-06-03 15:11:03 -03:00
Michelle Tang d9f93294e4 Turn on KT. 2026-06-03 15:06:31 -03:00
Greyson Parrelli f063c43b52 Fix session initialization in MessageService. 2026-06-03 15:06:31 -03:00
Michelle Tang a7ed672634 Stop unregistered KT failures. 2026-06-03 15:06:31 -03:00
Michelle Tang 1371663163 Add capability for KT username syncs. 2026-06-03 15:06:31 -03:00
Alex Hart 1f0c24a5d5 Add bank transfer fix for pill size and state readout. 2026-06-03 15:06:31 -03:00
Cody Henthorne b732cbe00b Disable video mirroring for screen share preview in calls. 2026-06-03 15:06:31 -03:00
dependabot[bot] 85d60dd0da Bump GH actions versions for checkout and stale. 2026-06-03 15:06:31 -03:00
andrew-signal c020bfeb7a Bump to libsignal v0.94.4 2026-06-03 15:06:31 -03:00
Greyson Parrelli 3ad446c6c9 Render legacy encryption error messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli bc9c560f96 Add support for optional remote build cache. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 2b54dc4715 Ensure we uppercase AEP entry, add tests. 2026-06-03 15:06:31 -03:00
Michelle Tang 1443457eca Fix pluralization of pin attempt string. 2026-06-03 15:06:31 -03:00
Cody Henthorne ffbc4465bb Improve attachment and delete database operations. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4e5ddad78f Fixed linkifying URLs with commas. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 47a69d667c Hide video overlay stub when binding text-only quoted messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 9d3a51def2 Check battery optimization status before showing general battery saver prompt. 2026-06-03 15:06:31 -03:00
Greyson Parrelli b8c964846c Add gradle cache support to CI. 2026-06-03 15:06:31 -03:00
jeffrey-signal b02210c166 Fix medium sized devices using bar navigation instead of rail navigation. 2026-06-03 15:06:31 -03:00
Greyson Parrelli c2f8261419 Use better index for story query. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 089d47936b Log query analysis of slow queries for internal users. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 48f55bba0a Add search to internal preferences. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 348387f2d0 Fix v2 message sync sends over gRPC. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 30c0ef255a Upgrade wire to 6.4.0 2026-06-03 15:06:31 -03:00
Greyson Parrelli 64d3ba9e5b Add android tools checksum to Dockerfile. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 930a2f052a Remove SDK guard on voice note comparison. 2026-06-03 15:06:31 -03:00
Greyson Parrelli efec070728 Force screen security flag on PIN screens. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 99aa8a602b Force a WAL checkpoint after message deletions. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4a68e0c469 Fix backup progress being stuck. 2026-06-03 15:06:31 -03:00
Michelle Tang be80619a3b Disappear 1:1 calls. 2026-06-03 15:06:31 -03:00
fethij a0d605d1b1 Fix integer division in failed-service-start heuristic 2026-06-03 15:06:31 -03:00
Alex Hart 64f30bff47 Use scrollable tab mode for donation receipt list to prevent text truncation.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 15:06:31 -03:00
Greyson Parrelli 843b656fb6 Use specific remote config for slow database notification. 2026-06-03 15:06:31 -03:00
Cody Henthorne f5f5bf0a67 Use view stubs for heavy, low use compose drafting views. 2026-06-03 15:06:31 -03:00
Cody Henthorne bf73954f42 Add indexes for slow database queries and optimize view-once query. 2026-06-03 13:55:36 -04:00
Cody Henthorne 1fd651ee50 Fix answered ringing group calls getting marked as missed. 2026-06-03 13:55:36 -04:00
Greyson Parrelli f76292769a Update MessageApiV2 to use libsignal-net. 2026-06-03 13:55:36 -04:00
Cody Henthorne 51c4afe5f5 Add missing unidentified status data to individual send v2 sync message. 2026-06-03 13:55:36 -04:00
Cody Henthorne fe435433fd Add missing handler for group request join/cancel update.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 13:55:36 -04:00
andrew-signal fa8098a9aa Bump to libsignal v0.94.2. 2026-06-03 13:55:36 -04:00
BarbossHack 81e09e65cb Fix resources.arsc comparison in reproducible script.
Resolves signalapp/Signal-Android#14817
2026-06-03 13:55:36 -04:00
jeffrey-signal 1059fcafba Fix minimum length hint on RegV5 PIN creation screen. 2026-06-03 13:55:36 -04:00
Cody Henthorne 91d3fa8ad5 Fix bugs around stalled media restore and add recovery logic. 2026-06-03 13:55:36 -04:00
Cody Henthorne 89bffe39ae Improve cold start performance. 2026-06-03 13:55:36 -04:00
Cody Henthorne de2a5ea440 Add internal slow database transaction logging and alerting. 2026-06-03 13:55:36 -04:00
Cody Henthorne a54d62c09d Fix db access on main thread during conversation open. 2026-06-03 13:55:36 -04:00
jeffrey-signal e8785218a5 Add RegV5 confirm PIN UI. 2026-06-03 13:55:36 -04:00
Cody Henthorne e6e6075c9b Fix potential Recipient.resolve on main thread for group thread list item. 2026-06-03 13:55:36 -04:00
Alex Hart 95b69faa58 Cap persisted image-editor undo history to avoid crash. 2026-06-03 13:55:36 -04:00
Cody Henthorne b7f09ef923 Retry delivery receipt send on generic IO failures. 2026-06-03 13:55:36 -04:00
Alex Hart 50884e144e Add linked device registration skeleton UI. 2026-06-03 13:55:35 -04:00
Alex Hart 274feb168e Fix story preview thumbnails not rendering in contact picker. 2026-06-03 13:55:35 -04:00
jeffrey-signal 5b1f5a2a20 Ensure consistent regV5 footer padding and elevation. 2026-06-03 13:55:35 -04:00
dependabot[bot] 2cb9685024 Bump actions/stale from 10.2.0 to 10.3.0 in the actions group.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 13:55:35 -04:00
Greyson Parrelli 0f4fc74829 Reduce unit test parallelism. 2026-06-03 13:55:35 -04:00
Cody Henthorne f5e8e15785 Add index for notification state query. 2026-06-03 13:55:35 -04:00
jeffrey-signal 3ff1501090 Adaptive PIN entry screen. 2026-06-03 13:55:35 -04:00
Greyson Parrelli 0907898105 Have QA task use debug variants. 2026-06-03 13:55:35 -04:00
Cody Henthorne 5fd8101180 Fix spinner build. 2026-06-03 13:55:35 -04:00
Michelle Tang 7e6ad92ca8 Turn off KT. 2026-06-02 23:58:41 -04:00
Michelle Tang ae2477356b Bump version to 8.13.1 2026-05-29 10:58:26 -04:00
Michelle Tang 8d992fa7db Update baseline profile. 2026-05-29 10:55:07 -04:00
Michelle Tang 71c33fc579 Update translations and other static files. 2026-05-29 10:37:38 -04:00
Greyson Parrelli bdefc274b9 Start using updated character set for AEP. 2026-05-29 09:54:58 -04:00
Greyson Parrelli f926d9c893 Update SVR2 enclave. 2026-05-28 00:23:29 -04:00
Michelle Tang ec02d802f2 Bump version to 8.13.0 2026-05-27 14:52:19 -04:00
Michelle Tang 7925041982 Update translations and other static files. 2026-05-27 14:44:57 -04:00
Michelle Tang de42d748dc Ignore tests. 2026-05-27 14:37:51 -04:00
Michelle Tang 3e1df5b8ac Turn on KT. 2026-05-27 14:08:54 -04:00
Greyson Parrelli f1d722c5cd Fix test dependency import. 2026-05-27 14:08:54 -04:00
Cody Henthorne abcd65603c Fix block/unblock causing ANR in recipient bottom sheet. 2026-05-27 14:08:54 -04:00
Greyson Parrelli 7b23110cac Improve instrumentation setup. 2026-05-27 14:08:54 -04:00
Greyson Parrelli baa4dd3c86 Improve handling of devices without Play Services. 2026-05-27 14:08:54 -04:00
Greyson Parrelli 0beda1e615 Cancel delayed notifications when notification state is empty. 2026-05-27 14:01:29 -04:00
Michelle Tang b3acefdb08 Fix instrumentation tests. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 9600366422 Fix instrumentation tests. 2026-05-27 14:01:29 -04:00
jeffrey-signal d85a57adce Add adaptive layout support to regV5 PIN creation screen. 2026-05-27 14:01:29 -04:00
jeffrey-signal cb1b878198 Add string resources for the PIN entry screen. 2026-05-27 14:01:29 -04:00
jeffrey-signal 4a2e2e0137 Add string resources for the PIN creation screen. 2026-05-27 14:01:29 -04:00
Greyson Parrelli b2f8445e1d Upgrade CameraX to 1.6.1 2026-05-27 14:01:29 -04:00
Alex Hart f5f686fece Add call-link specific network error handling.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
fm-sys 1864534174 Add long press to copy app version code.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>

Closes signalapp/Signal-Android#14795
2026-05-27 14:01:29 -04:00
Alex Hart 54c4bda4f2 Allow local backup restore to proceed regardless of remote restore failure state.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
Alex Hart 3cb61e3e8a Remove inaccurate recovery key mention from local backup restore dialog.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
andrew-signal 83a83f65ef Prune some unused resources. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 122e1770b7 Hide keyboard on contact selection when add-message is disabled. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 68eb4d3c82 Propagate force flag to story attachment downloads. 2026-05-27 14:01:29 -04:00
Greyson Parrelli ce4d68a20f Filter shortcode numbers in externalContact to prevent E164 re-formatting crash. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 5d45914a08 Improved send performance. 2026-05-27 14:01:29 -04:00
Greyson Parrelli b2f450d849 Add index to make marking unread faster. 2026-05-27 14:01:29 -04:00
Cody Henthorne ed73dc2c24 Remove system call triggering location request. 2026-05-27 14:01:28 -04:00
andrew-signal 71967b278e Remove cluster of orphaned code around CameraContactSelectionViewController. 2026-05-27 14:01:28 -04:00
andrew-signal faadc9854f Re-encode call ringing/busy tones from MP3 to opus. 2026-05-27 14:01:28 -04:00
Greyson Parrelli f206487ede Add MessageService and IndividualSendJobV2. 2026-05-27 14:01:28 -04:00
Greyson Parrelli 0284da2d0f Move a lot of utils into core. 2026-05-27 14:01:28 -04:00
Alex Hart 15a3a8efde Add debug log pop (8 clicks) for reg v5.
Co-authored-by: Michelle Tang <mtang@signal.org>
2026-05-27 14:01:28 -04:00
andrew-signal b429d08c5e Exclude libsignal-testing.md acknowledgment from non-test builds. 2026-05-27 14:01:28 -04:00
Jim Gustafson 3892113a0e Update to RingRTC v2.69.2 2026-05-27 14:01:28 -04:00
Cody Henthorne 41f52ed886 Fix thrashing storage service fields. 2026-05-27 14:01:28 -04:00
Cody Henthorne ec07b7805e Fix incorrect sync message being sent when network drops group send response. 2026-05-27 14:01:28 -04:00
Alexandria 333e514a2f Fix media review confirm button color.
Closes signalapp/Signal-Android#14683
2026-05-27 14:01:28 -04:00
Michelle Tang 15a17adf1a Bump version to 8.12.4 2026-05-27 13:53:20 -04:00
Michelle Tang 13888bab0a Update translations and other static files. 2026-05-27 13:24:56 -04:00
Alex Hart 16fc81f715 Use bitmap avatar icons on API 36+ to avoid BadForegroundServiceNotificationException.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 13:03:24 -04:00
Alex Hart b4f2d8682f Always load avatar in the background. 2026-05-27 12:54:11 -03:00
Michelle Tang a05ce88bc0 Bump version to 8.12.3 2026-05-26 16:17:51 -04:00
Michelle Tang 59c38147c7 Update translations and other static files. 2026-05-26 16:07:46 -04:00
Michelle Tang da85d0b4eb Turn off KT. 2026-05-26 15:34:19 -04:00
Cody Henthorne 5aa7e3a7c1 Only take remote release note sync data if present. 2026-05-26 12:33:42 -04:00
Alex Hart c92b0505df Move ContactSearchPagedDataSource.size() off the main thread to fix ANR.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-26 12:55:50 -03:00
Michelle Tang ca8c494fc4 Reset KT for username changes in storage service. 2026-05-26 11:43:39 -04:00
Cody Henthorne fbbcadf09b Bump version to 8.12.2 2026-05-24 14:05:47 -04:00
Cody Henthorne 3fc6ac3871 Update translations and other static files. 2026-05-24 13:56:15 -04:00
Cody Henthorne 6a2ec01c52 Fix duplicate key crash in contact search lists. 2026-05-24 13:29:41 -04:00
Cody Henthorne c1b3fb6d1b Fix profile fetch crash when skipping debounce. 2026-05-24 10:32:58 -04:00
jeffrey-signal 792d86f4d8 Bump version to 8.12.1 2026-05-21 17:06:28 -04:00
jeffrey-signal 849856cde8 Update baseline profile. 2026-05-21 16:58:59 -04:00
jeffrey-signal 0646418d4d Update translations and other static files. 2026-05-21 16:36:58 -04:00
Alex Hart ed540a2f9e Update model key entry. 2026-05-21 16:57:14 -03:00
Cody Henthorne 00d86101f5 Fix crash when no recent recipients for profile refresh.
Fixes #14791
2026-05-21 15:38:37 -04:00
jeffrey-signal 86e49cd564 Bump version to 8.12.0 2026-05-21 11:12:07 -04:00
jeffrey-signal 21b7d64fcd Update baseline profile. 2026-05-21 10:50:28 -04:00
jeffrey-signal 42c0044096 Update translations and other static files. 2026-05-21 10:43:07 -04:00
Michelle Tang f2e8b83604 Update reg v5 UI for quick restore. 2026-05-21 10:38:23 -04:00
Michelle Tang 46b8ac6561 Update reg v5 UI for local backup v1 account. 2026-05-21 10:38:22 -04:00
Michelle Tang 9089cc393e Update reg v5 UI for locked account. 2026-05-21 10:38:22 -04:00
Alex Hart 2ea59bef68 Handle PniChangeNumber sync on linked devices. 2026-05-21 10:38:22 -04:00
Alex Hart 698fc38aed Migrate ContactSearch RV to MappingLazyColumn. 2026-05-21 10:38:22 -04:00
Michelle Tang 1d74b00b91 Update local backups reg v5 UI. 2026-05-21 10:38:21 -04:00
Greyson Parrelli ea861fff49 Remove unnecessary link test. 2026-05-21 10:38:21 -04:00
Cody Henthorne 3b93edcdaf Add verification code requested alert handling. 2026-05-21 10:38:21 -04:00
Cody Henthorne 6722a28f98 Fix broken linkify unit tests. 2026-05-21 10:38:21 -04:00
Cody Henthorne 16de2efa9e Fix non-instrumentation variants not being able to run unit tests. 2026-05-21 10:38:20 -04:00
Michelle Tang 4d0919c9a8 Fix minor safety number UI issues. 2026-05-21 10:38:20 -04:00
Greyson Parrelli 01e1cb4d67 Hide keyboard before navigating to chat settings. 2026-05-21 10:38:20 -04:00
Michelle Tang 9d1d5142da Turn on key transparency. 2026-05-21 10:38:20 -04:00
Alex Hart 49f0c2502b Add IncomingMessageObserver integration test infrastructure. 2026-05-21 10:38:19 -04:00
Michelle Tang 71ffc36e7f Update key transparency string. 2026-05-21 10:38:19 -04:00
Greyson Parrelli 5941ff814d Improve perf for backup info in debuglogs. 2026-05-21 10:38:19 -04:00
Cody Henthorne 1661f3b5f7 Improve profile fetch performance for large groups. 2026-05-21 10:38:18 -04:00
Cody Henthorne d682de08d2 Sort pinned chats like chat list for shortcut list. 2026-05-21 10:38:18 -04:00
Cody Henthorne 5321f8124a Add additional download checks to long message text attachments. 2026-05-21 10:38:18 -04:00
Michelle Tang 5052f22d44 Fix local restore crash from file selection. 2026-05-21 10:38:18 -04:00
Michelle Tang 9a8cb1785b Update reg v5 to use new scaffold. 2026-05-21 10:38:17 -04:00
Cody Henthorne a2065becdd fixup! Fix chat bubbles not rendering due to ConstraintLayout bug. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e85637a58d Inline useBinaryId remote config. 2026-05-21 10:38:17 -04:00
Greyson Parrelli 73c3d141e3 Remove USE_STRING_ID build config. 2026-05-21 10:38:17 -04:00
jeffrey-signal eafba156ba RegistrationScaffold will automatically add top padding when there is no header. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e6beafd612 Update video demo project to support batch transcoding. 2026-05-21 10:38:16 -04:00
Cody Henthorne a9649fd017 Enforce change number post registration delay. 2026-05-21 10:38:16 -04:00
adel-signal 4decae274b Update to RingRTC v2.69.1 2026-05-21 10:38:16 -04:00
gram-signal dbb83d86e3 Add remote config for requirePqRatio. 2026-05-21 10:38:15 -04:00
Greyson Parrelli 2aa27df95b Add SignalRestClient. 2026-05-21 10:38:15 -04:00
Cody Henthorne ec47b83f76 Add sync message encrypt local metric to send flows. 2026-05-21 10:38:15 -04:00
Cody Henthorne 6eea4ba937 Sync release note channel settings with storage service. 2026-05-21 10:38:15 -04:00
Cody Henthorne 9f608337f1 Add friendly toasts for forced remote config refresh. 2026-05-21 10:38:14 -04:00
jeffrey-signal 28edcdf62d Update regV5 permissions screen to use RegistrationScaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 10d969ea35 Add two pane registration scaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 38bac16640 Add separate window breakpoints for windows with large widths vs large heights. 2026-05-21 10:36:50 -04:00
jeffrey-signal 93077ac457 Bump version to 8.11.4 2026-05-21 10:24:18 -04:00
jeffrey-signal c069eb1b88 Update baseline profile. 2026-05-21 10:08:50 -04:00
jeffrey-signal e5cd18bf1e Update translations and other static files. 2026-05-21 10:02:16 -04:00
Alex Hart 9e8ae7e26a Only update desktop activity timestamp for user-initiated sync messages.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-20 12:42:52 -03:00
Cody Henthorne 00042b9579 Stop screen sharing when disabled from system UI. 2026-05-20 10:06:18 -04:00
Greyson Parrelli e750b81a31 Disable dnsjava hosts file parsing to fix NPE race condition. 2026-05-20 10:05:39 -04:00
Greyson Parrelli daec317f52 Don't auto-snooze donation megaphones. 2026-05-20 10:02:35 -04:00
Greyson Parrelli 112514c221 Remove persistent play services error notification.
Fixes #14786
2026-05-19 18:05:05 -04:00
Cody Henthorne f43db8ace0 Fix chat bubbles not rendering due to ConstraintLayout bug.
Resolves signalapp/Signal-Android#14774
2026-05-19 18:05:04 -04:00
jeffrey-signal 54df95727b Bump version to 8.11.3 2026-05-19 12:12:47 -04:00
jeffrey-signal 022b4d9508 Update baseline profile. 2026-05-19 11:58:37 -04:00
jeffrey-signal 7411e725ec Update translations and other static files. 2026-05-19 11:51:57 -04:00
Alex Hart 83a279f422 Fix display bug with donation type. 2026-05-19 11:42:12 -04:00
Michelle Tang 523066d093 Turn off key transparency. 2026-05-19 10:39:06 -04:00
Michelle Tang de27343c24 Update key transparency api. 2026-05-19 10:38:08 -04:00
Michelle Tang c36179293e Fix missing safety number dialog. 2026-05-18 14:13:59 -04:00
andrew-signal a79a91bafb Bump to libsignal v0.94.1 2026-05-18 14:09:27 -04:00
Michelle Tang 13de1ede90 Bump version to 8.11.2 2026-05-15 16:32:54 -04:00
Michelle Tang b94f420393 Update translations and other static files. 2026-05-15 16:29:45 -04:00
Michelle Tang 4909f130cc Animate safety number once. 2026-05-15 16:21:45 -04:00
Michelle Tang 0010386b9e Bump version to 8.11.1 2026-05-14 15:20:56 -04:00
Michelle Tang 02c760945d Update translations and other static files. 2026-05-14 14:58:23 -04:00
Greyson Parrelli a0247bb8cc Add R8 keep rule for org.signal.network to fix Jackson deserialization. 2026-05-14 14:43:36 -04:00
Greyson Parrelli bcfec5de50 Fix benchmark build. 2026-05-14 14:36:10 -04:00
Cody Henthorne b2215915ef Switch to speaker and disable proximity when screen sharing. 2026-05-14 14:36:10 -04:00
1217 changed files with 65983 additions and 26787 deletions
+19 -4
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- 'main'
- '7.**'
- '8.**'
permissions:
contents: read # to fetch code (actions/checkout)
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
@@ -27,14 +27,29 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
with:
# Only 8.** branch builds write to the cache; everything else (PRs, etc.) reads only.
cache-read-only: ${{ !startsWith(github.ref, 'refs/heads/8.') }}
# Required to persist the Gradle configuration cache across runs.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# Pull requests run the fast custom linter (ci); pushes to main / 8.x branches run the full
# Android lint (qa).
- name: Build with Gradle
run: ./gradlew qa
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
- name: Archive reports for failed build
if: ${{ failure() }}
+11 -3
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
@@ -28,7 +28,15 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Set up Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
with:
# PR-only workflow: always read from the cache, never write.
cache-read-only: true
# Required to read the Gradle configuration cache persisted by 8.** builds.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
@@ -53,7 +61,7 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
actions: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
+30 -23
View File
@@ -27,8 +27,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1688
val canonicalVersionName = "8.11.0"
val canonicalVersionCode = 1705
val canonicalVersionName = "8.15.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -56,6 +56,11 @@ val localProperties: Properties? = if (localPropertiesFile.exists()) {
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
val benchmarkBackupFile: String? = localProperties?.getProperty("benchmark.backup.file")
val isInstrumentationTestRun = gradle.startParameter.taskNames.any { taskName ->
val lower = taskName.lowercase()
lower.contains("androidtest") || lower.contains("connectedcheck")
}
val selectableVariants = listOf(
"nightlyProdSpinner",
"nightlyProdPerf",
@@ -68,13 +73,11 @@ val selectableVariants = listOf(
"playProdMocked",
"playProdNonMinifiedMocked",
"playProdBenchmark",
"playProdInstrumentation",
"playProdRelease",
"playStagingDebug",
"playStagingCanary",
"playStagingSpinner",
"playStagingPerf",
"playStagingInstrumentation",
"playStagingRelease",
"playProdQuickstart",
"playStagingQuickstart",
@@ -100,6 +103,9 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).conf
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
source("$projectDir/src/benchmarkShared/java")
}
if (isTestTask && name.contains("AndroidTest")) {
source("$projectDir/src/benchmarkShared/java")
}
}
wire {
@@ -129,7 +135,6 @@ android {
ndkVersion = libs.versions.ndk.get()
flavorDimensions += listOf("distribution", "environment")
testBuildType = "instrumentation"
android.bundle.language.enableSplit = false
@@ -168,6 +173,7 @@ android {
getByName("androidTest") {
java.srcDir("$projectDir/src/testShared")
java.srcDir("$projectDir/src/benchmarkShared/java")
}
}
@@ -215,6 +221,10 @@ android {
versionCode = (canonicalVersionCode * maxHotfixVersions) + possibleHotfixVersions[currentHotfixVersion]
versionName = canonicalVersionName
if (isInstrumentationTestRun) {
applicationIdSuffix = ".test_run"
}
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
@@ -249,8 +259,8 @@ android {
buildConfigField("String[]", "SIGNAL_CDSI_IPS", rootProject.extra["cdsi_ips"] as String)
buildConfigField("String[]", "SIGNAL_SVR2_IPS", rootProject.extra["svr2_ips"] as String)
buildConfigField("String", "SIGNAL_AGENT", "\"OWA\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\", \"BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
@@ -272,7 +282,6 @@ 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", "false")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -288,7 +297,11 @@ android {
}
}
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
testInstrumentationRunner = if (project.hasProperty("imoTests")) {
"org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner"
} else {
"org.thoughtcrime.securesms.testing.SignalTestRunner"
}
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
@@ -337,17 +350,6 @@ android {
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Release\"")
}
create("instrumentation") {
initWith(getByName("debug"))
isDefault = false
isMinifyEnabled = false
matchingFallbacks += "debug"
applicationIdSuffix = ".instrumentation"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"")
}
create("spinner") {
initWith(getByName("debug"))
isDefault = false
@@ -516,6 +518,9 @@ android {
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
if (variant.enable) {
(variant as? com.android.build.api.variant.HasUnitTestBuilder)?.enableUnitTest = true
}
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Rename APK to include version name
@@ -527,9 +532,10 @@ androidComponents {
transformationRequest.set(renameRequest)
}
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
// Include the test-only library on non-release builds.
if (variant.buildType == "release") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
variant.androidResources.ignoreAssetsPatterns.add("libsignal-testing.md")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
@@ -715,6 +721,7 @@ dependencies {
}
implementation(libs.dnsjava)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.arrow.core)
implementation(libs.accompanist.permissions)
implementation(libs.accompanist.drawablepainter)
implementation(libs.kotlin.stdlib.jdk8)
@@ -736,7 +743,7 @@ dependencies {
"canaryImplementation"(libs.square.leakcanary)
"instrumentationImplementation"(libs.androidx.fragment.testing) {
androidTestImplementation(libs.androidx.fragment.testing) {
exclude(group = "androidx.test", module = "core")
}
+22 -1055
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@
-keep class org.signal.libsignal.usernames.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.signal.donations.json.** { *; }
-keep class org.signal.network.** { *; }
-keepclassmembers class ** {
public void onEvent*(**);
}
@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverDependencyProvider
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner
/**
* Application used when running `IncomingMessageObserver` instrumentation tests. Installs
* [IncomingMessageObserverDependencyProvider] so the websocket and job manager are replaced
* with test-friendly implementations. Selected by [IncomingMessageObserverTestRunner] when
* gradle is invoked with `-PimoTests`.
*/
class IncomingMessageObserverInstrumentationApplicationContext : ApplicationContext() {
override fun initializeAppDependencies() {
val default = ApplicationDependencyProvider(this)
AppDependencies.init(this, IncomingMessageObserverDependencyProvider(this, default))
AppDependencies.deadlockDetector.start()
}
override fun initializeLogging() {
Log.initialize({ true }, AndroidLogger)
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
}
override fun beginJobLoop() = Unit
fun beginJobLoopForTests() {
super.beginJobLoop()
}
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
@@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDepende
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.logging.PersistentLogger
import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.util.Environment
/**
* Application context for running instrumentation tests (aka androidTests).
@@ -19,6 +21,11 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
val inMemoryLogger: InMemoryLogger = InMemoryLogger()
override fun attachBaseContext(base: Context?) {
Environment.IS_INSTRUMENTATION = true
super.attachBaseContext(base)
}
override fun initializeAppDependencies() {
val default = ApplicationDependencyProvider(this)
AppDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default))
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.CDN_3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -0,0 +1,393 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isGreaterThanOrEqualTo
import assertk.assertions.isLessThan
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end UI test of the unread divider. Seeds a thread with many unread messages and opens it via the notification
* path (which enters the conversation with no explicit jump point — functionally "open a chat with X unread"), then
* verifies the real pipeline (repository -> view model -> fragment -> decoration) anchors the divider to the oldest
* unread message and scrolls there rather than opening at the bottom.
*
* The launch harness mirrors [org.thoughtcrime.securesms.main.MainNavigationLaunchTest]: ActivityScenario can't track
* MainActivity launched with a custom-action intent, so we start it via Application#startActivity and observe lifecycle
* callbacks instead.
*/
@RunWith(AndroidJUnit4::class)
class UnreadDividerInstrumentationTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
@Test
fun opensScrolledToOldestUnreadWithCorrectDividerState() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val totalUnread = 50
val oldestSentTime = 1000L
var oldestUnreadId = -1L
for (i in 0 until totalUnread) {
val id = insertIncoming(threadId, recipientId, time = oldestSentTime + i, body = "unread $i")
if (i == 0) {
oldestUnreadId = id
}
}
// Derive expectations from the DB the same way the app does, so the test is robust to any extra system rows.
val expectedUnreadCount = SignalDatabase.messages.getUnreadCount(threadId)
val firstUnreadPosition = SignalDatabase.messages.getMessagePositionByDateReceivedTimestamp(threadId, oldestSentTime, false)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "conversation scrolled to oldest unread") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
val state = decoration.unreadStateForTesting as? ConversationItemDecorations.UnreadState.CompleteUnreadState ?: return@await null
val view = recycler.layoutManager?.findViewByPosition(firstUnreadPosition) ?: return@await null
Observed(state.unreadCount, state.firstUnreadId, view.top, recycler.height)
}
assertThat(result.unreadCount).isEqualTo(expectedUnreadCount)
assertThat(result.firstUnreadId).isEqualTo(oldestUnreadId)
// The oldest unread is laid out in the top half -> we scrolled up to it instead of opening at the bottom (where,
// with this many messages, it would be off-screen above and findViewByPosition would have returned null).
assertThat(result.firstUnreadTop).isGreaterThanOrEqualTo(0)
assertThat(result.firstUnreadTop).isLessThan(result.recyclerHeight / 2)
}
}
@Test
fun fullyReadConversationOpensAtBottomWithoutDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "read $i")
}
SignalDatabase.threads.setRead(threadId)
// Precondition: nothing is unread, so there should be no divider.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isEqualTo(0)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "fully-read conversation opened at the bottom") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// The newest message is position 0; if it's laid out, the list loaded and settled at the bottom.
val newest = recycler.layoutManager?.findViewByPosition(0) ?: return@await null
BottomObserved(decoration.unreadStateForTesting, newest.bottom, recycler.height)
}
assertThat(result.unreadState).isEqualTo(ConversationItemDecorations.UnreadState.None)
// Newest message sits in the lower half -> opened at the bottom (with this many messages it would be off-screen
// below if we'd opened at the top).
assertThat(result.newestBottom).isGreaterThan(result.recyclerHeight / 2)
}
}
@Test
fun outgoingMessageNewerThanUnreadClearsDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
// A few unread incoming messages, then a newer outgoing reply. Kept small so all rows load in the initial page.
insertIncoming(threadId, recipientId, time = 1000L, body = "unread 0")
insertIncoming(threadId, recipientId, time = 1001L, body = "unread 1")
insertIncoming(threadId, recipientId, time = 1002L, body = "unread 2")
val outgoing = OutgoingMessage.text(
threadRecipient = Recipient.resolved(recipientId),
body = "my reply",
expiresIn = 0,
sentTimeMillis = 1003L
)
SignalDatabase.messages.insertMessageOutbox(outgoing, threadId)
// Precondition: the messages are still unread at the DB level, so the divider would show if it weren't for the
// newer outgoing message clearing it.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
launch(recipientId).use { launched ->
val cleared = await(timeoutMs = 20_000, description = "divider cleared by newer outgoing message") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// Wait until the list has loaded (outgoing at position 0 laid out) before reading the resolved state.
recycler.layoutManager?.findViewByPosition(0) ?: return@await null
if (decoration.unreadStateForTesting == ConversationItemDecorations.UnreadState.None) true else null
}
assertThat(cleared).isEqualTo(true)
}
}
@Test
fun scrollingToBottomMarksEverythingReadAndDrainsUnreadCount() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
// getUnreadCount is the shared source for the chat-list badge and the scroll-to-bottom button's count, so
// asserting on it verifies the number the user sees updating as they scroll.
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
// Jump to the newest message; revealing it marks every earlier message read (MarkReadHelper.onViewsRevealed).
runOnMain {
launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)?.scrollToPosition(0)
}
// Scrolling through the thread drains the unread count to 0.
await(timeoutMs = 20_000, description = "unread count reaches 0 after scrolling to the bottom") {
if (SignalDatabase.messages.getUnreadCount(threadId) == 0) true else null
}
}
}
@Test
fun scrollingPartwayLeavesExactlyTheUnreadMessagesBelowTheViewport() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
// The chat opens at the oldest unread (near the top); scroll down to roughly the middle.
runOnMain {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(total / 2, 0)
}
// Once mark-read settles, the unread count must equal the index of the newest visible message — i.e. exactly the
// messages still below the viewport (reverse layout: position 0 = newest, so index N = N newer messages). This is
// the number the scroll-to-bottom button and chat-list badge show; it must not over- or under-count mid-scroll.
val stableCount = awaitStableUnreadCount(threadId)
val newestVisiblePosition = await(timeoutMs = 5_000, description = "newest visible position") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.takeIf { it >= 0 }
}
assertThat(stableCount).isEqualTo(newestVisiblePosition)
// Sanity: we exercised a genuine mid-scroll point, not the very top or bottom.
assertThat(stableCount).isGreaterThan(0)
assertThat(stableCount).isLessThan(total)
}
}
/** Polls [MessageTable.getUnreadCount] until it holds steady (mark-read is debounced + async), then returns it. */
private fun awaitStableUnreadCount(threadId: Long, timeoutMs: Long = 20_000): Int {
val deadline = System.currentTimeMillis() + timeoutMs
var last = Int.MIN_VALUE
var stableSince = System.currentTimeMillis()
while (System.currentTimeMillis() < deadline) {
val current = SignalDatabase.messages.getUnreadCount(threadId)
if (current == last) {
if (System.currentTimeMillis() - stableSince >= 500) {
return current
}
} else {
last = current
stableSince = System.currentTimeMillis()
}
Thread.sleep(100)
}
throw AssertionError("Unread count never stabilized (last observed = $last)")
}
private data class BottomObserved(
val unreadState: ConversationItemDecorations.UnreadState,
val newestBottom: Int,
val recyclerHeight: Int
)
private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, body: String): Long {
val message = IncomingMessage(
type = MessageType.NORMAL,
from = from,
sentTimeMillis = time,
serverTimeMillis = time,
receivedTimeMillis = time,
body = body
)
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
}
private data class Observed(
val unreadCount: Int,
val firstUnreadId: Long,
val firstUnreadTop: Int,
val recyclerHeight: Int
)
private fun RecyclerView.conversationItemDecorations(): ConversationItemDecorations? {
for (i in 0 until itemDecorationCount) {
val decoration = getItemDecorationAt(i)
if (decoration is ConversationItemDecorations) {
return decoration
}
}
return null
}
private fun runOnMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync { block() }
}
/** Polls [block] on the main thread until it returns non-null, failing after [timeoutMs]. */
private fun <T> await(timeoutMs: Long, pollMs: Long = 100, description: String, block: () -> T?): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
var value: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync { value = block() }
if (value != null) {
return value!!
}
Thread.sleep(pollMs)
}
throw AssertionError("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun launch(recipientId: RecipientId): Launched {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val conversationFragments: MutableList<ConversationFragment> = Collections.synchronizedList(mutableListOf())
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val fragmentCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
if (f is ConversationFragment) {
conversationFragments.add(f)
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
if (f is ConversationFragment) {
conversationFragments.remove(f)
}
}
}
val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities.add(activity)
if (activity is MainActivity) {
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true)
}
}
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) {
resumed.countDown()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
allActivities.remove(activity)
}
}
app.registerActivityLifecycleCallbacks(activityCallbacks)
// Open the conversation the way a notification tap does: a conversation intent with no starting position.
val conversationIntent = ConversationIntents.createBuilder(harness.context, recipientId, -1L).blockingGet().build()
val intent = Intent(harness.context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
// Application#startActivity from a non-Activity context requires a new task.
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
app.startActivity(intent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw AssertionError("MainActivity did not reach RESUMED within 15s")
}
return Launched(conversationFragments, app, activityCallbacks, allActivities)
}
private class Launched(
private val conversationFragments: List<ConversationFragment>,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
fun latestConversationFragment(): ConversationFragment? = synchronized(conversationFragments) { conversationFragments.lastOrNull() }
override fun close() {
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
}
@@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.database
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import kotlin.random.Random
@@ -431,7 +431,7 @@ class CallTableTest {
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(1L, call?.timestamp)
}
@@ -1,50 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
class InAppPaymentTableTest {
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
}
@Test
fun givenACreatedInAppPayment_whenIUpdateToPending_thenIExpectPendingPayment() {
val inAppPaymentId = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData()
)
val paymentBeforeUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentBeforeUpdate?.state).isEqualTo(InAppPaymentTable.State.CREATED)
SignalDatabase.inAppPayments.update(
inAppPayment = paymentBeforeUpdate!!.copy(state = InAppPaymentTable.State.PENDING)
)
val paymentAfterUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentAfterUpdate?.state).isEqualTo(InAppPaymentTable.State.PENDING)
}
}
@@ -1,174 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
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.UuidUtil
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.StorageId
import java.time.DayOfWeek
import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile as RemoteNotificationProfile
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
@RunWith(AndroidJUnit4::class)
class NotificationProfileTablesTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var alice: RecipientId
private lateinit var profile1: NotificationProfile
@Before
fun setUp() {
alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
profile1 = NotificationProfile(
id = 1,
name = "profile1",
emoji = "",
createdAt = 1000L,
schedule = NotificationProfileSchedule(id = 1),
allowedMembers = setOf(alice),
notificationProfileId = NotificationProfileId.generate(),
deletedTimestampMs = 0,
storageServiceId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3))
)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileScheduleTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileAllowedMembersTable.TABLE_NAME)
}
@Test
fun givenARemoteProfile_whenIInsertLocally_thenIExpectAListWithThatProfile() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
allowedMembers = listOf(RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString()))),
allowAllMentions = false,
allowAllCalls = true,
scheduleEnabled = false,
scheduleStartTime = 900,
scheduleEndTime = 1700,
scheduleDaysEnabled = emptyList(),
deletedAtTimestampMs = 0
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
val actualProfiles = SignalDatabase.notificationProfiles.getProfiles()
assertEquals(listOf(profile1), actualProfiles)
}
@Test
fun givenAProfile_whenIDeleteIt_thenIExpectAnEmptyList() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
assertThat(SignalDatabase.notificationProfiles.getProfiles()).isEmpty()
assertThat(SignalDatabase.notificationProfiles.getProfile(profile.id))
}
@Test
fun givenADeletedProfile_whenIGetIt_thenIExpectItToStillHaveASchedule() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
val deletedProfile = SignalDatabase.notificationProfiles.getProfile(profile.id)!!
assertThat(deletedProfile.schedule.enabled).isFalse()
assertThat(deletedProfile.schedule.start).isEqualTo(900)
assertThat(deletedProfile.schedule.end).isEqualTo(1700)
assertThat(deletedProfile.schedule.daysEnabled, "Contains correct default days")
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)
}
@Test
fun givenNotificationProfiles_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
SignalDatabase.notificationProfiles.createProfile(
name = "Profile1",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
)
SignalDatabase.notificationProfiles.createProfile(
name = "Profile2",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 2000L
)
val existingMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, _) ->
SignalDatabase.notificationProfiles.applyStorageIdUpdate(id, StorageId.forNotificationProfile(StorageSyncHelper.generateKey()))
}
val updatedMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, storageId) ->
assertNotEquals(storageId, updatedMap[id])
}
}
@Test
fun givenAProfileDeletedOver30Days_whenICleanUp_thenIExpectItToNotHaveAStorageId() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
deletedAtTimestampMs = 1000L
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty()
}
private val NotificationProfileTables.NotificationProfileChangeResult.profile: NotificationProfile
get() = (this as NotificationProfileTables.NotificationProfileChangeResult.Success).notificationProfile
}
@@ -8,10 +8,17 @@ package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.nullIfBlank
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageRecordUpdate
@@ -19,8 +26,11 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.signalAci
import org.whispersystems.signalservice.api.storage.signalPni
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -60,4 +70,46 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
val messages = MessageTableTestUtils.getMessages(SignalDatabase.threads.getThreadIdFor(other.id)!!)
assertThat(messages.first().isIdentityDefault).isTrue()
}
@Test
fun givenAnAlreadySyncedContact_whenMarkedUnregistered_thenItSplitsAndPublishesTheSplit() {
// GIVEN a registered contact with aci+pni+e164 that is already in storage service (has a storageId)
val aci = ACI.from(UUID.randomUUID())
val pni = PNI.from(UUID.randomUUID())
val e164 = "+13334445555"
val id = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164)
SignalDatabase.recipients.markRegistered(id, aci)
val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(originalStorageId).isNotNull()
// Sanity: the record it currently publishes is whole + registered.
val before = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(before.signalAci).isEqualTo(aci)
assertThat(before.signalPni).isEqualTo(pni)
assertThat(before.unregisteredAtTimestamp).isEqualTo(0L)
// WHEN it is marked unregistered (which strips its pni/e164 and splits it)
SignalDatabase.recipients.markUnregistered(id)
// THEN its storageId rotates
val updatedStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(updatedStorageId).isNotNull()
assertThat(originalStorageId!!.contentEquals(updatedStorageId!!)).isFalse()
// THEN the published record is now ACI-only + unregistered
val after = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(after.signalAci).isEqualTo(aci)
assertThat(after.signalPni).isNull()
assertThat(after.e164.nullIfBlank()).isNull()
assertThat(after.unregisteredAtTimestamp > 0L).isTrue()
// THEN the number now lives on a separate PNI-only recipient, so no whole aci+pni+e164 record remains.
val byPni = SignalDatabase.recipients.getByPni(pni).get()
assertThat(byPni).isNotEqualTo(id)
val pniRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(byPni)!!).proto.contact!!
assertThat(pniRecord.signalAci).isNull()
assertThat(pniRecord.signalPni).isEqualTo(pni)
}
}
@@ -1,120 +0,0 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.SqlUtil
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.whispersystems.signalservice.api.push.DistributionId
import java.util.UUID
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
@RunWith(AndroidJUnit4::class)
class MyStoryMigrationTest {
@get:Rule val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
@Test
fun givenAValidMyStory_whenIMigrate_thenIExpectMyStoryToBeValid() {
// GIVEN
assertValidMyStoryExists()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenNoMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
deleteMyStory()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenA00000000DistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId("0000-0000")
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenARandomDistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId(UUID.randomUUID().toString())
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
private fun setMyStoryDistributionId(serializedId: String) {
SignalDatabase.rawDatabase.update(
DistributionListTables.LIST_TABLE_NAME,
contentValuesOf(
DistributionListTables.DISTRIBUTION_ID to serializedId
),
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun deleteMyStory() {
SignalDatabase.rawDatabase.delete(
DistributionListTables.LIST_TABLE_NAME,
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun assertValidMyStoryExists() {
SignalDatabase.rawDatabase.query(
DistributionListTables.LIST_TABLE_NAME,
SqlUtil.COUNT,
"_id = ? AND ${DistributionListTables.DISTRIBUTION_ID} = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY, DistributionId.MY_STORY.toString()),
null,
null,
null
).use {
if (it.moveToNext()) {
val count = it.getInt(0)
assertEquals("assertValidMyStoryExists: Query produced an unexpected count.", 1, count)
} else {
fail("assertValidMyStoryExists: Query did not produce a count.")
}
}
}
private fun runMigration() {
V151_MyStoryMigration.migrate(
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
SignalSQLiteDatabase(SignalDatabase.rawDatabase),
0,
1
)
}
}
@@ -10,11 +10,11 @@ import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.PushServiceSocket
/**
@@ -41,7 +41,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return recipientCache
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
return mockk()
}
@@ -52,12 +52,11 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
override fun provideSignalServiceMessageSender(
protocolStore: SignalServiceDataStore,
pushServiceSocket: PushServiceSocket,
attachmentApi: AttachmentApi,
messageApi: MessageApi,
keysApi: KeysApi
): SignalServiceMessageSender {
if (signalServiceMessageSender == null) {
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi))
}
return signalServiceMessageSender!!
}
@@ -75,6 +75,14 @@ class EditMessageSyncProcessorTest {
.timestamp(originalTimestamp)
.expirationStartTimestamp(originalTimestamp)
.message(content.dataMessage)
.unidentifiedStatus(
listOf(
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder()
.destinationServiceIdBinary(toRecipient.requireServiceId().toByteString())
.unidentified(true)
.build()
)
)
.build()
).build()
).build()
@@ -100,6 +108,14 @@ class EditMessageSyncProcessorTest {
.targetSentTimestamp(originalTimestamp)
.build()
)
.unidentifiedStatus(
listOf(
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder()
.destinationServiceIdBinary(toRecipient.requireServiceId().toByteString())
.unidentified(true)
.build()
)
)
.build()
).build()
).build()
@@ -13,7 +13,6 @@ import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.After
import org.junit.Before
import org.junit.Ignore
@@ -25,6 +24,7 @@ import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.IdentityUtil
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.util.UUID
@@ -166,7 +165,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
assertThat(messageCount).isEqualTo(0)
val threadRecord = SignalDatabase.threads.getThreadRecord(threadId)
assertThat(threadRecord).isNull()
assertThat(threadRecord?.active).isEqualTo(false)
}
@Test
@@ -245,7 +244,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
@Test
@@ -304,7 +303,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
harness.inMemoryLogger.flush()
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Using backup non-expiring messages") == true }).hasSize(1)
@@ -344,7 +343,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
@Test
@@ -376,7 +375,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(3)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
}
@Test
@@ -405,7 +404,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
@Test
@@ -435,7 +434,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
threadIds.forEach {
assertThat(SignalDatabase.messages.getMessageCountForThread(it)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(it)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(it)?.active).isEqualTo(false)
}
}
@@ -463,7 +462,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
}
@Ignore("counts are consistent for some reason")
@@ -527,10 +526,10 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(aliceThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(aliceThreadId)?.active).isEqualTo(false)
assertThat(SignalDatabase.messages.getMessageCountForThread(groupThreadId)).isEqualTo(0)
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(groupThreadId)?.active).isEqualTo(false)
}
@Test
@@ -551,7 +550,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// THEN
assertThat(SignalDatabase.messages.getMessageCountForThread(threadId)).isEqualTo(20)
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNotNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(true)
harness.inMemoryLogger.flush()
assertThat(harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }).hasSize(1)
@@ -665,7 +664,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
assertThat(updatedAttachments).isEmpty()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)).isNull()
assertThat(SignalDatabase.threads.getThreadRecord(threadId)?.active).isEqualTo(false)
}
private fun DatabaseAttachment.copy(
@@ -0,0 +1,353 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId
import org.signal.core.util.UuidUtil
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_synchronizePniChangeNumber {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var messageHelper: MessageHelper
private val newPniUuid: UUID = UUID.randomUUID()
private val newPni: ServiceId.PNI = ServiceId.PNI.from(newPniUuid)
// 16-byte raw UUID — matches the actual wire format the server sends (per proto comment and
// iOS/Desktop behavior). Do NOT use `newPni.toByteString()` here — that produces libsignal's
// 17-byte ServiceIdBinary form, which is a different format.
private val newPniBytes: ByteString = UuidUtil.toByteArray(newPniUuid).toByteString()
private val newE164 = "+15555550199"
private val newPniIdentity: IdentityKeyPair = IdentityKeyPair.generate()
private val newSignedPreKey: SignedPreKeyRecord = PreKeyUtil.generateSignedPreKey(1234, newPniIdentity.privateKey)
private val newLastResortKyber: KyberPreKeyRecord = PreKeyUtil.generateLastResortKyberPreKey(5678, newPniIdentity.privateKey)
private val newRegistrationId = 4242
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
SignalStore.account.deviceId = 2
}
@After
fun tearDown() {
messageHelper.tearDown()
}
@Test
fun appliesAllStateOnHappyPath() {
sendPniChangeNumber()
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(newLastResortKyber.id)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
val self = Recipient.self().fresh()
assertThat(self.requireE164()).isEqualTo(newE164)
assertThat(self.pni.orNull()).isEqualTo(newPni)
val pniProtocolStore = AppDependencies.protocolStore.pni()
val storedSigned = pniProtocolStore.loadSignedPreKey(newSignedPreKey.id)
assertThat(storedSigned.serialize().toByteString()).isEqualTo(newSignedPreKey.serialize().toByteString())
val storedKyber = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == newLastResortKyber.id }
assertThat(storedKyber).isNotNull()
assertThat(storedKyber!!.serialize().toByteString()).isEqualTo(newLastResortKyber.serialize().toByteString())
// The IdentityTable cache is keyed by ServiceId string, not RecipientId — for self, that's
// separate ACI and PNI rows. We want the PNI row, so look it up by the new PNI directly.
val selfPniIdentity = pniProtocolStore.getIdentity(SignalProtocolAddress(newPni.toString(), SignalServiceAddress.DEFAULT_DEVICE_ID))
assertThat(selfPniIdentity).isNotNull()
assertThat(selfPniIdentity!!.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
}
@Test
fun appliesStateWhenLastResortKyberAbsent() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = null)
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
// No kyber was supplied, so kyber metadata should be unchanged.
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
}
@Test
fun bailsWhenPrimaryDevice() {
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
val original = captureOriginalState()
sendPniChangeNumber()
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSourceIsNotPrimaryDevice() {
val original = captureOriginalState()
sendPniChangeNumber(sourceDeviceId = 3)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenEnvelopePniMissing() {
val original = captureOriginalState()
sendPniChangeNumber(envelopePniBinary = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenIdentityKeyPairMissing() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSignedPreKeyMissing() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdMissing() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdZero() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = 0)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Missing() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Empty() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "")
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164NotValid() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "not a phone number")
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedIdentityKeyPair() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedSignedPreKey() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedLastResortKyber() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
val otherSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, otherIdentity.privateKey)
sendPniChangeNumber(
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() + 1000
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun bailsWhenServerTimestampStale() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherPniUuid = UUID.randomUUID()
val otherPniBytes = UuidUtil.toByteArray(otherPniUuid).toByteString()
sendPniChangeNumber(
envelopePniBinary = otherPniBytes,
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() - 100_000L
)
assertOriginalStatePreserved(afterFirstApply)
}
private fun captureOriginalState(): OriginalState {
val self = Recipient.self().fresh()
return OriginalState(
e164 = SignalStore.account.e164,
pni = SignalStore.account.pni,
pniRegistrationId = SignalStore.account.pniRegistrationId,
isSignedPreKeyRegistered = SignalStore.account.pniPreKeys.isSignedPreKeyRegistered,
activeSignedPreKeyId = SignalStore.account.pniPreKeys.activeSignedPreKeyId,
lastResortKyberPreKeyId = SignalStore.account.pniPreKeys.lastResortKyberPreKeyId,
pniIdentityPublicKey = SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString(),
selfE164 = self.e164.orNull(),
selfPni = self.pni.orNull(),
forcePniSignedPreKeyRotation = SignalStore.misc.forcePniSignedPreKeyRotation
)
}
private fun assertOriginalStatePreserved(original: OriginalState) {
assertThat(SignalStore.account.e164).isEqualTo(original.e164)
assertThat(SignalStore.account.pni).isEqualTo(original.pni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(original.pniRegistrationId)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isEqualTo(original.isSignedPreKeyRegistered)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(original.activeSignedPreKeyId)
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(original.pniIdentityPublicKey)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isEqualTo(original.forcePniSignedPreKeyRotation)
val self = Recipient.self().fresh()
assertThat(self.e164.orNull()).isEqualTo(original.selfE164)
assertThat(self.pni.orNull()).isEqualTo(original.selfPni)
}
private data class OriginalState(
val e164: String?,
val pni: ServiceId.PNI?,
val pniRegistrationId: Int,
val isSignedPreKeyRegistered: Boolean,
val activeSignedPreKeyId: Int,
val lastResortKyberPreKeyId: Int,
val pniIdentityPublicKey: ByteString,
val selfE164: String?,
val selfPni: ServiceId.PNI?,
val forcePniSignedPreKeyRotation: Boolean
)
private fun malformedBytes(): ByteString = byteArrayOf(0x00, 0x01, 0x02).toByteString()
private fun sendPniChangeNumber(
identityKeyPair: ByteString? = newPniIdentity.serialize().toByteString(),
signedPreKey: ByteString? = newSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey: ByteString? = newLastResortKyber.serialize().toByteString(),
registrationId: Int? = newRegistrationId,
e164: String? = newE164,
envelopePniBinary: ByteString? = newPniBytes,
sourceDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID,
timestamp: Long = messageHelper.nextStartTime()
) {
val content = Content(
syncMessage = SyncMessage(
pniChangeNumber = SyncMessage.PniChangeNumber(
identityKeyPair = identityKeyPair,
signedPreKey = signedPreKey,
lastResortKyberPreKey = lastResortKyberPreKey,
registrationId = registrationId,
newE164 = e164
)
)
)
val envelope = MessageContentFuzzer.envelope(
timestamp = timestamp,
updatedPniBinary = envelopePniBinary
)
messageHelper.processor.process(
envelope = envelope,
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = sourceDeviceId),
serverDeliveredTimestamp = timestamp + 10
)
}
}
@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertNoMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class DecryptionErrorTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun malformedEnvelope_dropsMessage_butPipelineRecovers() {
val peer = rule.peers[0]
rule.deliver { malformedEnvelope() from peer }
assertNoMessageReceived(from = peer, body = "subsequent")
rule.deliver { text("subsequent") from peer }
assertMessageReceived(from = peer, body = "subsequent")
}
}
@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertGroupMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingGroupMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 5)
@Test
fun deliveredGroupText_isPersistedInGroupThread() {
val group = rule.testGroup
rule.deliver { groupText("hello group", group = group) from rule.peers[0] }
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "hello group")
}
@Test
fun multipleGroupMembers_messagesPersistedFromEach() {
val group = rule.testGroup
rule.deliver {
groupText("from peer 0", group = group) from rule.peers[0]
groupText("from peer 1", group = group) from rule.peers[1]
groupText("from peer 2", group = group) from rule.peers[2]
}
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "from peer 0")
assertGroupMessageReceived(from = rule.peers[1], group = group, body = "from peer 1")
assertGroupMessageReceived(from = rule.peers[2], group = group, body = "from peer 2")
}
}
@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingTextMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun deliveredOneToOneText_isPersisted() {
rule.deliver { text("hello world") from rule.peers[0] }
assertMessageReceived(from = rule.peers[0], body = "hello world")
}
}
@@ -8,9 +8,9 @@ package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import org.junit.rules.ExternalResource
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
/**
@@ -41,11 +41,12 @@ object MessageContentFuzzer {
/**
* Create an [Envelope].
*/
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID(), updatedPniBinary: ByteString? = null): Envelope {
return Envelope.Builder()
.clientTimestamp(timestamp)
.serverTimestamp(timestamp + 5)
.serverGuidBinary(serverGuid.toByteArray().toByteString())
.also { if (updatedPniBinary != null) it.updatedPniBinary(updatedPniBinary) }
.build()
}
@@ -139,7 +139,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import org.signal.benchmark.setup.OtherClient
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
/**
* Reads database state produced by [IncomingMessageObserverRule]-driven tests. Import members
* individually (e.g. `import …IncomingMessageObserverAssertions.assertMessageReceived`) so test
* bodies stay terse.
*/
object IncomingMessageObserverAssertions {
fun OtherClient.recipientId(): RecipientId = Recipient.externalPush(SignalServiceAddress(serviceId, e164)).id
fun findIncomingMessage(from: OtherClient, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(from.recipientId()) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader -> reader.firstOrNull { it.body == body } }
}
}
fun findIncomingGroupMessage(from: OtherClient, group: GroupHandle, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader ->
reader.firstOrNull { it.body == body && it.fromRecipient.id == from.recipientId() }
}
}
}
fun assertMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record, "incoming message with body \"$body\" from ${from.serviceId} not found").isNotNull()
assertThat(record!!.fromRecipient.id, "incoming message sender mismatch for body \"$body\"").isEqualTo(from.recipientId())
}
fun assertGroupMessageReceived(from: OtherClient, group: GroupHandle, body: String) {
val record = findIncomingGroupMessage(from, group, body)
assertThat(record, "group message \"$body\" from ${from.serviceId} in ${group.groupId} not found").isNotNull()
}
fun assertNoMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record == null, "expected no message with body \"$body\" from ${from.serviceId}, but found one").isTrue()
}
fun assertNoMessagesInThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return
val count = SignalDatabase.messages.getConversation(threadId).use { cursor -> cursor.count }
assertThat(count, "expected thread for $recipientId to be empty, but message count was").isEqualTo(0)
}
fun assertDeliveryReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasDeliveryReceipt(), "expected delivery receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
fun assertReadReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasReadReceipt(), "expected read receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
}
@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.core.util.UptimeSleepTimer
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobs.JobManagerFactories
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
/**
* Dependency provider used by [org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext].
* Composes [InstrumentationApplicationDependencyProvider] (so existing mocks for the account /
* archive / donations / billing APIs are reused) and overrides:
*
* - the auth and unauth websocket factories with [BenchmarkWebSocketConnection], so tests can
* inject encrypted envelopes through the real ingest pipeline;
* - the job manager, swapping the startup network jobs handled by [NoOpJob.replaceFactories]
* to no-ops so they can't fire against unstubbed mocks during a test.
*/
class IncomingMessageObserverDependencyProvider(
private val application: Application,
default: ApplicationDependencyProvider
) : AppDependencies.Provider by InstrumentationApplicationDependencyProvider(application, 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(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
.build()
return JobManager(application, config)
}
}
@@ -0,0 +1,201 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import okio.ByteString.Companion.toByteString
import org.junit.Assume
import org.junit.rules.ExternalResource
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.benchmark.setup.TestUsers
import org.signal.core.util.logging.Log
import org.signal.network.websocket.WebSocketRequestMessage
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* JUnit rule that drives [org.thoughtcrime.securesms.messages.IncomingMessageObserver] from
* instrumentation tests. Sets up self, registers [peerCount] simulated peers from
* [Harness.otherClients], establishes a Signal double-ratchet session with each, and exposes a
* small DSL for delivering encrypted envelopes through the real ingest pipeline:
*
* ```
* @get:Rule val rule = IncomingMessageObserverRule(peerCount = 2)
*
* @Test fun example() {
* rule.deliver { text("hi") from rule.peers[0] }
* rule.deliver { groupText("hi all", group = rule.testGroup) from rule.peers[0] }
* }
* ```
*
* Run with `-PimoTests`; tests are skipped under the default runner. Throws on drain timeout.
* Mutually exclusive with `SignalDatabaseRule` / `SignalActivityRule` — all three claim the
* local identity.
*/
class IncomingMessageObserverRule(
private val peerCount: Int = 2,
private val drainTimeout: Duration = 30.seconds
) : ExternalResource() {
lateinit var self: Recipient
private set
lateinit var peers: List<OtherClient>
private set
/** Lazily-created group. Touching this from a test triggers setup; tests that don't use groups pay nothing. */
val testGroup: GroupHandle by lazy {
val gid = TestUsers.setupGroup(withLabels = false)
GroupHandle(gid, Recipient.externalGroupExact(gid).id)
}
override fun before() {
Assume.assumeTrue(
"IncomingMessageObserverRule requires the IMO test runner — run with -PimoTests",
AppDependencies.application is IncomingMessageObserverInstrumentationApplicationContext
)
self = TestUsers.setupSelf()
TestUsers.setupTestClients(peerCount)
peers = Harness.otherClients.take(peerCount)
val app = AppDependencies.application as IncomingMessageObserverInstrumentationApplicationContext
app.beginJobLoopForTests()
// IncomingMessageObserver caches `canProcessMessages` from restoreDecisionState at thread
// construction. If it was built before setupSelf() flipped the state it will silently drop
// every message; reset network so a fresh observer is constructed.
AppDependencies.incomingMessageObserver.notifyRestoreDecisionMade()
AppDependencies.startNetwork()
forceObserverConstruction()
val handshakeEnvelopes = peers.map { client ->
client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
}
deliverEnvelopes(handshakeEnvelopes)
peers.forEach { it.completeSession() }
}
fun deliver(builder: DeliveryBuilder.() -> Unit) {
val collected = DeliveryBuilder().apply(builder).specs
if (collected.isEmpty()) return
deliverEnvelopes(collected.map { it.materialize() })
}
private fun forceObserverConstruction() {
AppDependencies.incomingMessageObserver
}
private fun deliverEnvelopes(envelopes: List<Envelope>) {
val jobManager = AppDependencies.jobManager
val seenQueues = CopyOnWriteArraySet<String>()
val queueListener = object : JobTracker.JobListener {
override fun onStateChanged(job: Job, jobState: JobTracker.JobState) {
job.parameters.queue?.let { queue ->
if (queue.startsWith(PushProcessMessageJob.QUEUE_PREFIX)) {
seenQueues += queue
}
}
}
}
jobManager.addListener({ job: Job -> job.parameters.queue?.startsWith(PushProcessMessageJob.QUEUE_PREFIX) == true }, queueListener)
try {
BenchmarkWebSocketConnection.addPendingMessages(envelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.addQueueEmptyMessage()
BenchmarkWebSocketConnection.releaseMessages()
val consumed = BenchmarkWebSocketConnection.awaitAllMessagesConsumed(drainTimeout.inWholeMilliseconds)
check(consumed) { "Timed out waiting for benchmark websocket to consume ${envelopes.size} envelope(s)" }
// PushProcessMessageJob enqueue happens on a background thread after the websocket marks
// messages consumed; this tick lets that settle before we snapshot the queues to wait on.
Thread.sleep(100)
val queuesToDrain = seenQueues.toSet()
Log.d(TAG, "Awaiting ${queuesToDrain.size} PushProcessMessageJob queue(s): $queuesToDrain")
for (queue in queuesToDrain) {
val state = jobManager.runSynchronously(MarkerJob(queue), drainTimeout.inWholeMilliseconds)
check(state.isPresent) { "Timed out waiting for queue $queue to drain" }
}
} finally {
jobManager.removeListener(queueListener)
}
}
companion object {
private val TAG = Log.tag(IncomingMessageObserverRule::class)
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage = WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random.nextLong(),
headers = listOf("X-Signal-Timestamp: $serverTimestamp"),
body = encodeByteString()
)
}
}
/** Identifies the test group created by [IncomingMessageObserverRule]. Hold a reference to pass into the [DeliveryBuilder.groupText] DSL. */
data class GroupHandle(val groupId: GroupId.V2, val recipientId: RecipientId)
/**
* Receiver of the DSL passed to [IncomingMessageObserverRule.deliver]. Construct content with
* [text] / [groupText] / [deliveryReceipts] / [readReceipts] / [malformedEnvelope] and chain
* with the [from] infix to attach a sending peer. Each `from` adds the resulting envelope to
* the batch that will be delivered when the lambda returns.
*/
class DeliveryBuilder internal constructor() {
internal val specs = mutableListOf<EnvelopeSpec>()
fun text(body: String, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group = null)
fun groupText(body: String, group: GroupHandle, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group)
fun deliveryReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.DeliveryReceipt(targets, sentAt)
fun readReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.ReadReceipt(targets, sentAt)
fun malformedEnvelope(timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Malformed(timestamp)
infix fun EnvelopeContentSpec.from(peer: OtherClient) {
specs += EnvelopeSpec(this, peer)
}
}
/** Opaque envelope content returned by [DeliveryBuilder]. Tests never construct or inspect variants directly; the type only appears as a return / receiver of the DSL methods. */
sealed class EnvelopeContentSpec {
internal data class Text(val body: String, val timestamp: Long, val group: GroupHandle?) : EnvelopeContentSpec()
internal data class DeliveryReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class ReadReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class Malformed(val timestamp: Long) : EnvelopeContentSpec()
}
internal data class EnvelopeSpec(val content: EnvelopeContentSpec, val peer: OtherClient) {
fun materialize(): Envelope = when (val c = content) {
is EnvelopeContentSpec.Text ->
peer.encrypt(Generator.encryptedTextMessage(c.timestamp, c.body, c.group?.let { Harness.groupMasterKey }))
is EnvelopeContentSpec.DeliveryReceipt ->
peer.encrypt(Generator.encryptedDeliveryReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.ReadReceipt ->
peer.encrypt(Generator.encryptedReadReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.Malformed -> {
val valid = peer.encrypt(Generator.encryptedTextMessage(c.timestamp))
val original = valid.content ?: error("Encrypted envelope unexpectedly had no content")
val corrupted = original.toByteArray().also { it[it.size / 2] = (it[it.size / 2].toInt() xor 0x01).toByte() }
valid.copy(content = corrupted.toByteString())
}
}
}
@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
/**
* Test runner that swaps in [IncomingMessageObserverInstrumentationApplicationContext] so the
* `IncomingMessageObserver` test harness can drive a faked websocket. Selected automatically by
* the build when `-PimoTests` is set.
*/
@Suppress("unused")
class IncomingMessageObserverTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, IncomingMessageObserverInstrumentationApplicationContext::class.java.name, context)
}
}
@@ -6,56 +6,14 @@
package org.thoughtcrime.securesms
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.core.util.UptimeSleepTimer
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.AvatarGroupsV2DownloadJob
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.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
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
@@ -97,85 +55,11 @@ class BenchmarkApplicationContext : ApplicationContext() {
)
}
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
)
)
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
.build()
return JobManager(application, config)
}
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
val blockedJobs = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.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,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.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)
}
}
}
}
@@ -0,0 +1,84 @@
package org.signal.benchmark.setup
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
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.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
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.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
/**
* A [Job] that does nothing and always succeeds. Test setups substitute this for jobs whose
* real implementations would hit the network at startup (and so would either generate noise
* against the [DeviceTransferBlockingInterceptor][org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor]
* or fail against unstubbed mocks). Use [replaceFactories] to apply the swap.
*/
class NoOpJob(parameters: Parameters) : Job(parameters) {
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 = NoOpJob(parameters)
}
companion object {
const val KEY = "NoOpJob"
private val STARTUP_NETWORK_JOB_KEYS: Set<String> = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.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,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
}
}
@@ -146,7 +146,7 @@ object TestMessages {
private fun imageAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -170,7 +170,7 @@ object TestMessages {
private fun voiceAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"audio/aac",
null,
Optional.empty(),
@@ -133,7 +133,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@@ -157,7 +157,7 @@ object TestUsers {
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.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, 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)
@@ -8,7 +8,10 @@ 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.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketResponseMessage
import org.signal.network.websocket.WebsocketResponse
import org.signal.core.util.JsonUtils
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.push.SendMessageResponse
@@ -65,6 +68,18 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
fun addQueueEmptyMessage() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
}
fun awaitAllMessagesConsumed(timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val activeInstances = synchronized(this) { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).toList() }
if (activeInstances.isNotEmpty() && activeInstances.all { it.incomingRequests.isEmpty() && it.incomingSemaphore.availablePermits() == 0 }) {
return true
}
Thread.sleep(25)
}
return false
}
}
override val name: String = "bench-${System.identityHashCode(this)}"
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic"
tools:ignore="UnusedAttribute" />
</manifest>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/signal_accent_green"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,4 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?>
<resources>
<string name="app_name">Signal (Instrumentation)</string>
</resources>
+1 -1
View File
@@ -1371,7 +1371,7 @@
<service
android:name=".gcm.FcmReceiveService"
android:exported="true">
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,11 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
private var afterScroll: (() -> Unit)? = null
// Backing state for scrollToPositionTopAligned; alignTopCorrected guards the one-shot corrective re-scroll.
private var alignTopPosition: Int = RecyclerView.NO_POSITION
private var alignTopInset: Int = 0
private var alignTopCorrected: Boolean = false
override fun supportsPredictiveItemAnimations(): Boolean {
return false
}
@@ -34,9 +39,23 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
*/
fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = RecyclerView.NO_POSITION
super.scrollToPositionWithOffset(position, offset)
}
/**
* Scroll so [position]'s decorated top (including any top decoration, e.g. the unread divider) lands [topInset] px
* below the top of the recycler. [afterScroll] fires once the alignment settles.
*/
fun scrollToPositionTopAligned(position: Int, topInset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = position
alignTopInset = topInset
alignTopCorrected = false
// Rough first pass: the exact offset needs the item's height, which isn't known until it's laid out (see onLayoutCompleted).
super.scrollToPositionWithOffset(position, height - topInset)
}
/**
* If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source,
* the base implementation clears the request as if it was never made.
@@ -64,10 +83,26 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
} else {
scrollToPosition(pendingScrollPosition)
}
} else {
afterScroll?.invoke()
afterScroll = null
return
}
// The target is now laid out, so its height is known. Correct the offset once so the decorated top sits at the
// requested inset, then let the next layout settle before notifying via afterScroll.
if (alignTopPosition != RecyclerView.NO_POSITION && !alignTopCorrected) {
val target = findViewByPosition(alignTopPosition)
if (target != null) {
alignTopCorrected = true
if (getDecoratedTop(target) != alignTopInset) {
val correctedOffset = (height - paddingBottom) - alignTopInset - getDecoratedMeasuredHeight(target)
super.scrollToPositionWithOffset(alignTopPosition, correctedOffset)
return
}
}
}
afterScroll?.invoke()
afterScroll = null
alignTopPosition = RecyclerView.NO_POSITION
}
companion object {
@@ -13,7 +13,8 @@ object AppCapabilities {
storage = storageCapable,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = false // TODO(michelle): Turn on once all clients support it and add a migration
)
}
}
@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -32,8 +33,10 @@ import net.zetetic.database.Logger;
import org.conscrypt.ConscryptSignal;
import org.greenrobot.eventbus.EventBus;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.AppForegroundObserver;
import org.signal.core.util.DiskUtil;
import org.signal.core.util.MemoryTracker;
import org.signal.core.util.Util;
import org.signal.core.util.concurrent.AnrDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
@@ -50,6 +53,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -58,6 +62,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
import org.thoughtcrime.securesms.glide.SignalGlideComponents;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
@@ -74,6 +79,7 @@ import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
import org.thoughtcrime.securesms.jobs.InAppPaymentAuthCheckJob;
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.jobs.MessageSendLogCleanupJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
@@ -82,13 +88,14 @@ import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.thoughtcrime.securesms.providers.BlobProvider;
@@ -104,10 +111,8 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
@@ -118,7 +123,6 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
@@ -226,7 +230,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
.addPostRender(MessageSendLogCleanupJob::enqueue)
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
@@ -274,7 +278,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
checkFreeDiskSpace();
MemoryTracker.start();
BackupSubscriptionCheckJob.enqueueIfAble();
CheckKeyTransparencyJob.enqueueIfNecessary(true);
CheckKeyTransparencyJob.enqueueIfNecessary(true, false);
AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
@@ -417,7 +421,12 @@ public class ApplicationContext extends Application implements AppForegroundObse
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
Environment.IS_LINK_AND_SYNC_AVAILABLE,
null,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
return Unit.INSTANCE;
}
)
);
}
@@ -448,22 +457,27 @@ public class ApplicationContext extends Application implements AppForegroundObse
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
Log.i(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
Log.w(TAG, "Play Services are newly-available. Enabling FCM and updating server.");
SignalStore.account().setFcmEnabled(true);
AppDependencies.getJobManager().startChain(new FcmRefreshJob())
.then(new RefreshAttributesJob())
.enqueue();
AppDependencies.resetNetwork();
AppDependencies.startNetwork();
IncomingMessageObserver.stopForegroundService(this);
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && SignalStore.account().isFcmEnabled()) {
Log.w(TAG, "Play Services are no longer available. Disabling FCM and updating server.");
SignalStore.account().setFcmEnabled(false);
SignalStore.account().setFcmToken(null);
AppDependencies.getJobManager().add(new RefreshAttributesJob());
Log.w(TAG, "Play Services are no longer available. Attempting to get an FCM token anyway.");
AppDependencies.getJobManager().add(new FcmRefreshJob());
} else if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.MISSING && (System.currentTimeMillis() - SignalStore.misc().getLastMissingPlayServicesFcmVerificationTime()) > TimeUnit.DAYS.toMillis(3)) {
Log.i(TAG, "Play Services are unavailable, but it's been long enough that we should check and see if we can get an FCM token anyway.");
AppDependencies.getJobManager().add(new FcmRefreshJob());
} else if (SignalStore.account().isFcmEnabled()) {
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
long now = System.currentTimeMillis();
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
Log.i(TAG, "Time for routine FCM token refresh.");
AppDependencies.getJobManager().add(new FcmRefreshJob());
}
} else {
@@ -16,7 +16,7 @@ import androidx.core.app.ActivityOptionsCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ConfigurationUtil;
import org.signal.core.util.ConfigurationUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
@@ -6,7 +6,7 @@ import androidx.lifecycle.LifecycleOwner;
import com.bumptech.glide.RequestManager;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient;
import java.util.Locale;
import java.util.Set;
@@ -14,7 +14,7 @@ import java.util.Set;
public interface BindableConversationListItem extends Unbindable {
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ThreadRecord thread,
@NonNull ThreadWithRecipient thread,
@NonNull RequestManager requestManager, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations,
@@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContract
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
import org.signal.core.util.ServiceUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.ServiceUtil
/**
* Authentication using phone biometric (face, fingerprint recognition) or device lock (pattern, pin or passphrase).
@@ -23,45 +23,25 @@ public final class BlockUnblockDialog {
private BlockUnblockDialog() {}
public static void showReportSpamFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onReportSpam,
@Nullable Runnable onBlockAndReportSpam)
{
SimpleTask.run(lifecycle,
() -> buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam),
AlertDialog.Builder::show);
buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam).show();
}
public static void showBlockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock)
{
SimpleTask.run(lifecycle,
() -> buildBlockFor(context, recipient, onBlock, null),
AlertDialog.Builder::show);
}
public static void showBlockAndReportSpamFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock,
@NonNull Runnable onBlockAndReportSpam)
{
SimpleTask.run(lifecycle,
() -> buildBlockFor(context, recipient, onBlock, onBlockAndReportSpam),
AlertDialog.Builder::show);
buildBlockFor(context, recipient, onBlock, null).show();
}
public static void showUnblockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onUnblock)
{
SimpleTask.run(lifecycle,
() -> buildUnblockFor(context, recipient, onUnblock),
AlertDialog.Builder::show);
buildUnblockFor(context, recipient, onUnblock).show();
}
@WorkerThread
@@ -34,7 +34,6 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.io.IOException;
import java.lang.ref.WeakReference;
@@ -49,8 +48,7 @@ import java.util.function.Consumer;
*/
public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
implements SwipeRefreshLayout.OnRefreshListener,
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.ScrollCallback
ContactSelectionListFragment.OnContactSelectedListener
{
private static final String TAG = Log.tag(ContactSelectionActivity.class);
@@ -136,17 +134,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ServiceUtil.getInputMethodManager(this)
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
toolbar.clearFocus();
}
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
private final WeakReference<ContactSelectionActivity> activity;
@@ -1,16 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByPhoneNumberModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByUsernameModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsBannerModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsModel
import org.thoughtcrime.securesms.ContactSelectionListModels.InviteToSignalModel
import org.thoughtcrime.securesms.ContactSelectionListModels.MoreHeaderModel
import org.thoughtcrime.securesms.ContactSelectionListModels.NewGroupModel
import org.thoughtcrime.securesms.ContactSelectionListModels.RefreshContactsModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
@@ -23,152 +26,19 @@ class ContactSelectionListAdapter(
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item))
registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item))
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
ContactSelectionListModels.registerNewGroup(this, onClickCallbacks::onNewGroupClicked)
ContactSelectionListModels.registerInviteToSignal(this, onClickCallbacks::onInviteToSignalClicked)
ContactSelectionListModels.registerFindContacts(this, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerFindContactsBanner(this, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerRefreshContacts(this, onClickCallbacks::onRefreshContactsClicked)
ContactSelectionListModels.registerMoreHeader(this)
ContactSelectionListModels.registerEmpty(this)
ContactSelectionListModels.registerFindByUsername(this, onClickCallbacks::onFindByUsernameClicked)
ContactSelectionListModels.registerFindByPhoneNumber(this, onClickCallbacks::onFindByPhoneNumberClicked)
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return section.types.size
}
@@ -179,15 +49,15 @@ class ContactSelectionListAdapter(
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return when (ArbitraryRow.fromCode(arbitrary.type)) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
return when (ContactSelectionListModels.ArbitraryRow.fromCode(arbitrary.type)) {
ContactSelectionListModels.ArbitraryRow.NEW_GROUP -> NewGroupModel()
ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ContactSelectionListModels.ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
}
}
}
@@ -18,10 +18,8 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import org.signal.core.ui.logging.LoggingFragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
@@ -38,27 +36,24 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.jetbrains.annotations.NotNull;
import org.signal.core.ui.logging.LoggingFragment;
import org.signal.core.ui.permissions.Permissions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
@@ -71,18 +66,19 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRep
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -92,14 +88,13 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
@@ -126,22 +121,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
@@ -168,14 +158,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
setNewCallCallback((NewCallCallback) context);
}
if (getParentFragment() instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) getParentFragment());
}
if (context instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) context);
}
if (getParentFragment() instanceof OnContactSelectedListener) {
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
}
@@ -221,10 +203,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.newCallCallback = callback;
}
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
@@ -259,10 +237,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
@@ -305,137 +281,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts
fixedContacts,
false
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
@@ -452,25 +302,83 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onLoadFinished(size);
}
},
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
ContactSelectionListModels.composeEntries(
new ContactSelectionListModels.Callback() {
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
}
});
}
),
new ContactSearchAdapter.ClickCallbacks() {
@Override
public void onStoryClicked(@NotNull View view, ContactSearchData.@NotNull Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(ContactSearchData.@NotNull Expand expand) {
contactSearchViewModel.expandSection(expand.getSectionKey());
}
@Override
public void onChatTypeClicked(@NotNull View view, ContactSearchData.@NotNull ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
@Override
public void onUnknownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
null,
new CallButtonClickCallbacks()
);
return view;
@@ -595,32 +503,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void reset() {
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit && innerRecyclerView != null) {
if (resetPositionOnCommit) {
resetPositionOnCommit = false;
innerRecyclerView.scrollToPosition(0);
contactSearchViewModel.requestScrollPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(innerRecyclerView);
if (useFastScroller) {
contactSearchViewModel.setFastScrollEnabled(true);
} else {
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
}
if (headerActionView.isEnabled() && !hasQueryFilter()) {
headerActionView.setVisibility(View.VISIBLE);
} else {
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
}
@@ -790,8 +689,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
} else {
return false;
}
@@ -933,19 +832,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
!hasQuery)
{
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.NEW_GROUP.getCode());
}
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME.getCode());
}
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
if (includeChatTypes && !hasQuery) {
@@ -967,10 +866,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
HeaderAction sectionHeaderAction = (headerActionProvider != null && !hasQuery) ? headerActionProvider.getHeaderAction() : null;
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf ? new RecipientTable.IncludeSelfMode.IncludeWithRemap(getString(R.string.note_to_self)) : RecipientTable.IncludeSelfMode.Exclude.INSTANCE,
transportType,
!hideHeader,
sectionHeaderAction,
null,
!hideLetterHeaders(),
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
@@ -1017,13 +918,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.MORE_HEADING.getCode());
if (hasContactsPermissions(requireContext())) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS.getCode());
} else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS.getCode());
}
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
@@ -1113,15 +1014,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
void onInvite();
}
public interface ScrollCallback {
void onBeginScroll();
}
public interface HeaderActionProvider {
@NonNull HeaderAction getHeaderAction();
}
public interface OnItemLongClickListener {
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, Consumer<Boolean> setIsDisplayingContextMenu);
}
}
@@ -0,0 +1,299 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSelectionListAdapter] on top of
* the base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers
* for registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider]
* (Compose).
*/
object ContactSelectionListModels {
fun registerNewGroup(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
NewGroupModel::class.java,
LayoutFactory({ NewGroupViewHolder(it, onClick) }, R.layout.contact_selection_new_group_item)
)
}
fun registerInviteToSignal(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
InviteToSignalModel::class.java,
LayoutFactory({ InviteToSignalViewHolder(it, onClick) }, R.layout.contact_selection_invite_action_item)
)
}
fun registerFindContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsModel::class.java,
LayoutFactory({ FindContactsViewHolder(it, onClick) }, R.layout.contact_selection_find_contacts_item)
)
}
fun registerFindContactsBanner(mappingAdapter: MappingAdapter, onDismiss: () -> Unit, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsBannerModel::class.java,
LayoutFactory({ FindContactsBannerViewHolder(it, onDismiss, onClick) }, R.layout.contact_selection_find_contacts_banner_item)
)
}
fun registerRefreshContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
RefreshContactsModel::class.java,
LayoutFactory({ RefreshContactsViewHolder(it, onClick) }, R.layout.contact_selection_refresh_action_item)
)
}
fun registerMoreHeader(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
MoreHeaderModel::class.java,
LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerEmpty(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)
)
}
fun registerFindByUsername(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByUsernameModel::class.java,
LayoutFactory({ FindByUsernameViewHolder(it, onClick) }, R.layout.contact_selection_find_by_username_item)
)
}
fun registerFindByPhoneNumber(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByPhoneNumberModel::class.java,
LayoutFactory({ FindByPhoneNumberViewHolder(it, onClick) }, R.layout.contact_selection_find_by_phone_number_item)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
*/
@JvmStatic
fun composeEntries(
callback: Callback
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<NewGroupModel> { context ->
LayoutFactory(
{ view -> NewGroupViewHolder(view, callback::onNewGroupClicked) },
R.layout.contact_selection_new_group_item
).createViewHolder(FrameLayout(context))
}
viewHolder<InviteToSignalModel> { context ->
LayoutFactory(
{ view -> InviteToSignalViewHolder(view, callback::onInviteToSignalClicked) },
R.layout.contact_selection_invite_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsModel> { context ->
LayoutFactory(
{ view -> FindContactsViewHolder(view, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsBannerModel> { context ->
LayoutFactory(
{ view -> FindContactsBannerViewHolder(view, callback::onDismissFindContactsBannerClicked, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_banner_item
).createViewHolder(FrameLayout(context))
}
viewHolder<RefreshContactsModel> { context ->
LayoutFactory(
{ view -> RefreshContactsViewHolder(view, callback::onRefreshContactsClicked) },
R.layout.contact_selection_refresh_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<MoreHeaderModel> { context ->
LayoutFactory(
{ view -> MoreHeaderViewHolder(view) },
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(context))
}
viewHolder<EmptyModel> { context ->
LayoutFactory(
{ view -> EmptyViewHolder(view) },
R.layout.contact_selection_empty_state
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByUsernameModel> { context ->
LayoutFactory(
{ view -> FindByUsernameViewHolder(view, callback::onFindByUsernameClicked) },
R.layout.contact_selection_find_by_username_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByPhoneNumberModel> { context ->
LayoutFactory(
{ view -> FindByPhoneNumberViewHolder(view, callback::onFindByPhoneNumberClicked) },
R.layout.contact_selection_find_by_phone_number_item
).createViewHolder(FrameLayout(context))
}
}.build()
}
interface Callback {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
fun onFindContactsClicked()
fun onDismissFindContactsBannerClicked()
fun onRefreshContactsClicked()
fun onFindByUsernameClicked()
fun onFindByPhoneNumberClicked()
}
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
}
}
@@ -87,6 +87,7 @@ import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.AppForegroundObserver
import org.signal.core.util.Util
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
@@ -116,6 +117,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
import org.thoughtcrime.securesms.components.snackbars.LocalSnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
import org.thoughtcrime.securesms.components.verificationrequested.VerificationCodeRequestedBottomSheet
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
@@ -178,7 +180,6 @@ import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.archive.StoryArchiveActivity
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -195,6 +196,7 @@ import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
class MainActivity :
@@ -357,6 +359,25 @@ class MainActivity :
}
}
}
launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
SignalStore
.account
.verificationCodeRequestedAtMsFlow
.filter { it > 0L }
.collect { requestedAt ->
val notificationThreshold = requestedAt + 10.minutes.inWholeMilliseconds
if (System.currentTimeMillis() < notificationThreshold) {
VerificationCodeRequestedBottomSheet.show(supportFragmentManager, requestedAt)
} else {
Log.i(TAG, "Verification code requested but is older than 10 minutes, not showing sheet")
}
SignalStore.account.verificationCodeRequestedAtMs = 0L
}
}
}
}
supportFragmentManager.setFragmentResultListener(
@@ -1031,6 +1052,13 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
if (!isTrustedConversationIntent(intent)) {
Log.w(TAG, "Received a conversation intent through an exported entry point. Ignoring its extras.")
intent.action = null
setIntent(intent)
return
}
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
@@ -1038,6 +1066,14 @@ class MainActivity :
}
}
/**
* While MainActivity isn't exporting, we have launcher aliases that are, so we verify that someone isn't launching us through those befre
* respecting various intent attributes.
*/
private fun isTrustedConversationIntent(intent: Intent): Boolean {
return intent.component?.className == MainActivity::class.java.name
}
private fun handleGroupLinkInIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -11,6 +11,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import org.signal.core.util.AppForegroundObserver
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.StreamUtil
import org.signal.core.util.getDownloadManager
@@ -18,7 +19,6 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.FileUtils
import java.io.FileInputStream
import java.io.IOException
@@ -61,21 +61,36 @@ object ApkUpdateInstaller {
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!userInitiated && !shouldAutoUpdate()) {
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
try {
installApk(context, downloadId, userInitiated)
context
.getDownloadManager()
.openDownloadedFile(downloadId)
.use { parcelFileDescriptor ->
val stream = FileInputStream(parcelFileDescriptor.fileDescriptor)
if (!MessageDigest.isEqual(FileUtils.getFileDigest(stream), digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
stream.channel.position(0)
installApk(context, downloadId, stream, userInitiated)
}
} catch (e: IOException) {
Log.w(TAG, "Hit IOException when trying to install APK!", e)
SignalStore.apkUpdate.clearDownloadAttributes()
@@ -88,17 +103,13 @@ object ApkUpdateInstaller {
}
@Throws(IOException::class, SecurityException::class)
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
if (apkInputStream == null) {
Log.w(TAG, "Could not open download APK input stream!")
return
}
private fun installApk(context: Context, downloadId: Long, apkInputStream: InputStream, userInitiated: Boolean) {
Log.d(TAG, "Beginning APK install...")
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
// Reject the session if the APK's declared package name doesn't match ours.
setAppPackageName(context.packageName)
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
// This lets us skip the system-generated notification.
if (Build.VERSION.SDK_INT >= 31) {
@@ -133,15 +144,6 @@ object ApkUpdateInstaller {
session.commit(installerPendingIntent.intentSender)
}
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = context.getDownloadManager().query(query)
@@ -12,12 +12,12 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.ServiceUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.util.ServiceUtil
object ApkUpdateNotifications {
@@ -10,11 +10,11 @@ import android.os.Parcelable
import androidx.core.os.ParcelCompat
import org.signal.blurhash.BlurHash
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.ParcelUtil
import org.signal.core.util.UuidUtil
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.ParcelUtil
import java.util.UUID
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@@ -31,7 +31,7 @@ fun Attachment.toAttachmentPointer(context: Context): AttachmentPointer? {
}
try {
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
var attachmentWidth = attachment.width
var attachmentHeight = attachment.height
@@ -41,12 +41,16 @@ enum class Cdn(private val value: Int) {
}
fun fromCdnNumber(cdnNumber: Int): Cdn {
return fromCdnNumberOrNull(cdnNumber) ?: throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
}
fun fromCdnNumberOrNull(cdnNumber: Int): Cdn? {
return when (cdnNumber) {
-1 -> S3
0 -> CDN_0
2 -> CDN_2
3 -> CDN_3
else -> throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
else -> null
}
}
}
@@ -5,11 +5,11 @@ import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.signal.blurhash.BlurHash
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.ParcelUtil
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.ParcelUtil
import java.util.UUID
class DatabaseAttachment : Attachment {
@@ -5,6 +5,7 @@ import android.os.Parcel
import androidx.annotation.VisibleForTesting
import org.signal.blurhash.BlurHash
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.whispersystems.signalservice.api.InvalidMessageStructureException
@@ -76,6 +77,8 @@ class PointerAttachment : Attachment {
override val thumbnailUri: Uri? = null
companion object {
private val TAG = Log.tag(PointerAttachment::class)
@JvmStatic
fun forPointers(pointers: Optional<List<SignalServiceAttachment>>): List<Attachment> {
if (!pointers.isPresent) {
@@ -102,6 +105,13 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdnNumber = pointer.get().asPointer().cdnNumber
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered an attachment pointer with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
return Optional.of(
@@ -110,7 +120,7 @@ class PointerAttachment : Attachment {
transferState = transferState,
size = pointer.get().asPointer().size.orElse(0).toLong(),
fileName = pointer.get().asPointer().fileName.orElse(null),
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
cdn = cdn,
location = pointer.get().asPointer().remoteId.toString(),
key = encodedKey,
iv = null,
@@ -145,7 +155,13 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
val cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered a quote thumbnail with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
if (cdn == Cdn.S3) {
return Optional.empty()
}
@@ -7,7 +7,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.signal.core.util.ParcelUtil;
import org.signal.core.util.Base64;
import java.io.IOException;
@@ -7,7 +7,7 @@ import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.os.Build
import androidx.annotation.RequiresApi
import org.thoughtcrime.securesms.util.ServiceUtil
import org.signal.core.util.ServiceUtil
abstract class AudioRecorderFocusManager(val context: Context) {
protected val audioManager: AudioManager = ServiceUtil.getAudioManager(context)
@@ -38,8 +38,8 @@ object AvatarPickerStorage {
.getAllAvatars()
.filterIsInstance<Avatar.Photo>()
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.map { it.name }
val inDatabaseFileNames = photoAvatars.mapTo(mutableSetOf()) { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.mapTo(mutableSetOf()) { it.name }
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
@@ -6,14 +6,18 @@
package org.thoughtcrime.securesms.backup
import androidx.annotation.WorkerThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
@@ -46,6 +50,8 @@ object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
@@ -61,7 +67,7 @@ object ArchiveUploadProgress {
/**
* Observe this to get updates on the current upload progress.
*/
val progress: Flow<ArchiveUploadProgressState> = _progress
val progress: SharedFlow<ArchiveUploadProgressState> = _progress
.throttleLatest(500.milliseconds) {
uploadProgress.state == ArchiveUploadProgressState.State.None ||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
@@ -114,6 +120,11 @@ object ArchiveUploadProgress {
}
.onStart { emit(uploadProgress) }
.flowOn(Dispatchers.IO)
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
init {
_progress.tryEmit(Unit)
}
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
@@ -6,8 +6,8 @@
package org.thoughtcrime.securesms.backup.v2
import androidx.annotation.VisibleForTesting
import org.signal.core.util.ThrottledDebouncer
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import kotlin.time.Duration.Companion.seconds
@@ -31,6 +31,9 @@ import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -157,6 +160,22 @@ object ArchiveRestoreProgress {
update()
}
/**
* Self-heal hook for restores that appear active (banner showing, media still remaining) but have no jobs left actually working on them.
*/
fun checkForStalledRestore() {
SignalExecutors.BOUNDED.execute {
val stalled = SignalStore.backup.restoreState.isMediaRestoreOperation &&
SignalDatabase.attachments.getRemainingRestorableAttachmentSize() > 0L &&
AppDependencies.jobManager.areFactoriesEmpty(setOf(RestoreAttachmentJob.KEY, RestoreLocalAttachmentJob.KEY, CheckRestoreMediaLeftJob.KEY))
if (stalled) {
Log.w(TAG, "Detected a stalled media restore with no active jobs. Enqueueing a check job to recover.")
CheckRestoreMediaLeftJob.enqueueStalledRecoveryCheck()
}
}
}
fun clearLocalRestoreDirectoryError() {
SignalStore.backup.localRestoreDirectoryError = false
update()
@@ -40,6 +40,7 @@ import org.signal.core.util.CursorUtil
import org.signal.core.util.DiskUtil
import org.signal.core.util.EventTimer
import org.signal.core.util.PendingIntentFlags.cancelCurrent
import org.signal.core.util.ServiceUtil
import org.signal.core.util.Stopwatch
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.LimitedWorker
@@ -71,6 +72,7 @@ import org.signal.network.NetworkResult
import org.signal.network.StatusCodeErrorAction
import org.signal.network.api.SvrBApi
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.rest.toNetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -146,7 +148,6 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService
import org.thoughtcrime.securesms.service.BackupProgressService
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
@@ -1464,6 +1465,7 @@ object BackupRepository {
}
SignalDatabase.remappedRecords.clearCache()
SignalDatabase.remappedRecords.trimStaleMappings()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()
@@ -1628,19 +1630,6 @@ object BackupRepository {
}
}
fun getResumableMessagesBackupUploadSpec(backupFileSize: Long): NetworkResult<ResumableMessagesBackupUploadSpec> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
.also { Log.i(TAG, "UploadFormResult: ${it::class.simpleName}") }
}
.then { form ->
SignalNetwork.archive.getBackupResumableUploadUrl(form)
.also { Log.i(TAG, "ResumableUploadUrlResult: ${it::class.simpleName}") }
.map { ResumableMessagesBackupUploadSpec(attachmentUploadForm = form, resumableUri = it) }
}
}
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
return initBackupAndFetchAuth()
.then { credential ->
@@ -123,7 +123,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
throw InvalidAttachmentException("empty content id")
}
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
SignalServiceAttachmentRemoteId.from(remoteLocation, cdn.cdnNumber) to cdn.cdnNumber
}
val key = Base64.decode(remoteKey)
@@ -61,7 +61,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME).seconds.inWholeMilliseconds.takeIf { it > 0 },
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.ForcedUnread,
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
style = ChatStyleConverter.constructRemoteChatStyle(
db = db,
@@ -45,6 +45,7 @@ import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.Hex
import org.signal.core.util.JsonUtils
import org.signal.core.util.ParallelEventTimer
import org.signal.core.util.StringUtil
import org.signal.core.util.UuidUtil
@@ -105,7 +106,6 @@ import org.thoughtcrime.securesms.payments.FailureReason
import org.thoughtcrime.securesms.payments.State
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.mb
import java.io.Closeable
@@ -435,7 +435,7 @@ class ChatItemArchiveExporter(
else -> {
val attachments = extraData.attachmentsById[record.id]
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker && !dbAttachment.quote }
if (sticker?.stickerLocator != null) {
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id], exportState = exportState)
@@ -852,8 +852,8 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
PaymentNotification()
} else {
PaymentNotification(
amountMob = payment.amount.serializeAmountString(),
feeMob = payment.fee.serializeAmountString(),
amountMob = payment.amount.requireMobileCoin().amountDecimalString,
feeMob = payment.fee.requireMobileCoin().amountDecimalString,
note = payment.note.takeUnless { it.isEmpty() },
transactionDetails = payment.toRemoteTransactionDetails()
)
@@ -46,7 +46,7 @@ object ChatArchiveImporter {
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
ThreadTable.PINNED_ORDER to chat.pinnedOrder,
ThreadTable.ARCHIVED to chat.archived.toInt(),
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.FORCED_UNREAD.serialize() else ThreadTable.ReadStatus.READ.serialize(),
ThreadTable.READ to if (chat.markedUnread) ThreadTable.ReadStatus.ForcedUnread.serialize() else ThreadTable.ReadStatus.Read.serialize(),
ThreadTable.ACTIVE to 1
)
.run()
@@ -27,6 +27,7 @@ import org.signal.archive.proto.ViewOnceMessage
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.JsonUtils
import org.signal.core.util.SqlUtil
import org.signal.core.util.UuidUtil
import org.signal.core.util.asList
@@ -59,7 +60,6 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
@@ -81,11 +81,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MessageUtil
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.internal.push.DataMessage
import java.math.BigInteger
import java.math.BigDecimal
import java.sql.SQLException
import java.util.Optional
import java.util.UUID
@@ -1064,8 +1063,8 @@ class ChatItemArchiveImporter(
private fun ContentValues.addPaymentTombstoneNoMetadata(paymentNotification: PaymentNotification) {
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
val amount = tryParseCryptoValue(paymentNotification.amountMob)
val fee = tryParseCryptoValue(paymentNotification.feeMob)
val amount = paymentNotification.amountMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
val fee = paymentNotification.feeMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
put(
MessageTable.MESSAGE_EXTRAS,
MessageExtras(
@@ -1119,26 +1118,15 @@ class ChatItemArchiveImporter(
return null
}
val amountCryptoValue = tryParseCryptoValue(this)
return if (amountCryptoValue != null) {
CryptoValueUtil.cryptoValueToMoney(amountCryptoValue)
} else {
return try {
Money.mobileCoin(BigDecimal(this))
} catch (e: NumberFormatException) {
null
} catch (e: ArithmeticException) {
null
}
}
private fun tryParseCryptoValue(bigIntegerString: String?): CryptoValue? {
if (bigIntegerString == null) {
return null
}
val amount = try {
BigInteger(bigIntegerString).toString()
} catch (e: NumberFormatException) {
return null
}
return CryptoValue(mobileCoinValue = CryptoValue.MobileCoinValue(picoMobileCoin = amount))
}
private fun ContentValues.addQuote(quote: Quote) {
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
this.put(MessageTable.QUOTE_AUTHOR, importState.requireLocalRecipientId(quote.authorId).serialize())
@@ -156,8 +156,7 @@ object AccountDataArchiveProcessor {
navigationBarSize = signalStore.settingsValues.useCompactNavigationBar.toRemoteNavigationBarSize()
).takeUnless { Environment.IS_INSTRUMENTATION && SignalStore.backup.importedEmptyAndroidSettings },
bioText = selfRecord.about ?: "",
bioEmoji = selfRecord.aboutEmoji ?: "",
keyTransparencyData = selfRecord.keyTransparencyData?.toByteString()
bioEmoji = selfRecord.aboutEmoji ?: ""
)
)
)
@@ -251,7 +250,7 @@ object AccountDataArchiveProcessor {
SignalStore.account.usernameLink = null
}
SignalDatabase.recipients.setKeyTransparencyData(Recipient.self().aci.get(), accountData.keyTransparencyData?.toByteArray())
SignalDatabase.recipients.clearSelfKeyTransparencyData()
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
@@ -59,11 +60,15 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Util
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
@@ -120,6 +125,7 @@ fun MessageBackupsKeyRecordScreen(
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
@@ -145,6 +151,39 @@ fun MessageBackupsKeyRecordScreen(
RecordScreenBackHandler()
}
var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) }
if (displayRecoveryKeyCopyWarning) {
val context = LocalContext.current
val url = stringResource(R.string.recovery_key_phishing_support_url)
val events: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported")
RecoveryKeyWarningSheetEvent.GotItClick -> {
onCopyToClipboardClick(backupKeyString)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
CommunicationActions.openBrowserLink(context, url)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.PasteKeyClick -> error("Not supported")
RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported")
}
}
ModalBottomSheet(
onDismissRequest = { displayRecoveryKeyCopyWarning = false },
dragHandle = { BottomSheets.Handle() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = events
)
}
}
Scaffolds.Settings(
title = "",
navigationIcon = SignalIcons.ArrowStart.imageVector,
@@ -227,7 +266,13 @@ fun MessageBackupsKeyRecordScreen(
item {
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
onClick = {
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
displayRecoveryKeyCopyWarning = true
} else {
onCopyToClipboardClick(backupKeyString)
}
}
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
@@ -52,6 +52,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ByteUnit
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.bytes
import org.signal.core.util.money.FiatMoney
@@ -60,7 +61,6 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.ByteUnit
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import org.signal.core.models.AccountEntropyPool
/**
* Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key).
*
* We scan anywhere within the text and try to match the key in as many forms as possible:
* upper/lowercase, with or without grouping spaces, and with or without the display characters
* (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than
* just the AEP shape) avoids false positives on any 64-character in-alphabet string.
*/
object RecoveryKeyDetector {
/**
* @param text the text to scan
* @param recoveryKey the user's own recovery key, or null if they don't have one yet
* @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when
* [recoveryKey] is null, so callers can bypass the check entirely for users without a key.
*/
fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean {
if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) {
return false
}
val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase()
return normalized.contains(recoveryKey.value)
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Wires this [ComposeText] so that pasting the user's own recovery key first shows
* [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the
* user explicitly confirms via that warning.
*
* Must be called once the [host]'s view has been created, as it registers a fragment result
* listener scoped to the host's view lifecycle.
*
* @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the
* keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use
* this to suppress that behavior while the warning is up.
* @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if
* any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input.
*/
fun ComposeText.guardAgainstRecoveryKeyPaste(
host: Fragment,
onWarningShown: () -> Unit = {},
onWarningDismissed: () -> Unit = {}
) {
var pendingPaste: CharSequence? = null
host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle ->
if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) {
pendingPaste?.let { insertText(it) }
}
pendingPaste = null
onWarningDismissed()
}
setOnPasteListener { pasteText ->
if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) {
pendingPaste = pasteText
onWarningShown()
RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null)
true
} else {
false
}
}
}
@@ -0,0 +1,100 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import android.app.Dialog
import android.content.DialogInterface
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
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.fragment.app.setFragmentResult
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.ComposeFullScreenDialogFragment
/**
* Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user
* attempts to paste their recovery key into the input field.
*
* A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean
* indicating whether the user chose to proceed with the paste. The host can rely on this firing for
* every dismissal path (paste, decline, or cancel) to restore its own state.
*/
class RecoveryKeyPasteWarningFragment : ComposeFullScreenDialogFragment() {
companion object {
const val REQUEST_KEY = "recovery_key_request"
}
private var shouldPaste = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).apply {
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
window?.setWindowAnimations(0)
}
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
putBoolean(REQUEST_KEY, shouldPaste)
}
)
super.onDismiss(dialog)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun DialogContent() {
var isDisplayingFinalWarningDialog by remember { mutableStateOf(false) }
val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.DoNotShareClick -> {
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.GotItClick -> error("Not supported for paste")
RecoveryKeyWarningSheetEvent.LearnMoreClick -> error("Not supported for paste")
RecoveryKeyWarningSheetEvent.PasteKeyClick -> {
shouldPaste = true
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.ShareKeyClick -> {
isDisplayingFinalWarningDialog = true
}
}
}
if (isDisplayingFinalWarningDialog) {
RecoveryKeyWarningDialog(
events = eventHandler
)
} else {
ModalBottomSheet(
onDismissRequest = { dismissAllowingStateLoss() },
dragHandle = { BottomSheets.Handle() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = eventHandler
)
}
}
}
}
@@ -0,0 +1,185 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun RecoveryKeyWarningSheetContent(
clipStage: ClipStage,
events: (RecoveryKeyWarningSheetEvent) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.horizontalGutters(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier
.padding(top = 20.dp, bottom = 16.dp)
.size(80.dp)
.background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape)
.padding(20.dp)
)
Text(
text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
val signalWillNeverMessageYou = stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you)
val recoveryKeyWarningBody = stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond)
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(signalWillNeverMessageYou)
}
append(" ")
append(recoveryKeyWarningBody)
},
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 75.dp)
)
when (clipStage) {
ClipStage.COPY -> CopyActionButtons(events = events)
ClipStage.PASTE -> PasteActionButtons(events = events)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
@Composable
fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.GotItClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it))
}
TextButton(onClick = {
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more))
}
}
@Composable
fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.DoNotShareClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key))
}
TextButton(onClick = {
events(RecoveryKeyWarningSheetEvent.ShareKeyClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key))
}
}
@Composable
fun RecoveryKeyWarningDialog(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
val bodyIntro = stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_your_recovery_key_with_anyone)
val bodyEmphasis = stringResource(R.string.RecoveryKeyWarningDialog__signal_will_never_message_you_for_your_recovery_key)
val bodyOutro = stringResource(R.string.RecoveryKeyWarningDialog__never_respond_to_a_chat)
Dialogs.SimpleAlertDialog(
title = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_recovery_key)),
body = buildAnnotatedString {
append(bodyIntro)
append(" ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(bodyEmphasis)
}
append(" ")
append(bodyOutro)
},
confirm = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__paste_key)),
confirmColor = MaterialTheme.colorScheme.error,
dismiss = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__dont_share)),
onConfirm = { events(RecoveryKeyWarningSheetEvent.PasteKeyClick) },
onDeny = { events(RecoveryKeyWarningSheetEvent.DoNotShareClick) }
)
}
enum class ClipStage {
COPY,
PASTE
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentCopyPreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentPastePreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningDialogPreview() {
Previews.Preview {
RecoveryKeyWarningDialog(events = {})
}
}
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
sealed interface RecoveryKeyWarningSheetEvent {
data object DoNotShareClick : RecoveryKeyWarningSheetEvent
data object ShareKeyClick : RecoveryKeyWarningSheetEvent
data object PasteKeyClick : RecoveryKeyWarningSheetEvent
data object GotItClick : RecoveryKeyWarningSheetEvent
data object LearnMoreClick : RecoveryKeyWarningSheetEvent
}
@@ -11,6 +11,7 @@ import org.signal.archive.proto.FilePointer
import org.signal.core.util.Base64
import org.signal.core.util.UuidUtil
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.core.util.orNull
import org.signal.libsignal.usernames.BaseUsernameException
@@ -32,6 +33,8 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import java.util.Optional
import org.signal.archive.proto.AvatarColor as RemoteAvatarColor
private const val TAG = "ArchiveConverter"
/**
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
*/
@@ -58,10 +61,16 @@ fun FilePointer?.toLocalAttachment(
return when (attachmentType) {
AttachmentType.ARCHIVE -> {
val cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber
if (Cdn.fromCdnNumberOrNull(cdnNumber) == null) {
Log.w(TAG, "Encountered an archived attachment with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return null
}
ArchivedAttachment(
contentType = contentType,
size = locatorInfo.size.toLong(),
cdn = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
cdn = cdnNumber,
uploadTimestamp = locatorInfo.transitTierUploadTimestamp ?: 0,
key = locatorInfo.key.toByteArray(),
cdnKey = locatorInfo.transitCdnKey?.nullIfBlank(),
@@ -87,7 +96,7 @@ fun FilePointer?.toLocalAttachment(
AttachmentType.TRANSIT -> {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!),
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!, locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber),
contentType = contentType,
key = locatorInfo.key.toByteArray(),
size = Optional.ofNullable(locatorInfo.size),
@@ -13,6 +13,7 @@ import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.Debouncer
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.money.FiatMoney
@@ -39,7 +40,6 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -306,7 +306,5 @@ class GiftFlowConfirmationFragment :
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Not supported for gifts")
override fun exitCheckoutFlow() {
requireActivity().finishAfterTransition()
}
override fun exitCheckoutFlow() = Unit
}
@@ -4,7 +4,6 @@ import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.findNavController
@@ -19,29 +18,25 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigur
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import kotlin.getValue
/**
* Allows the user to select a recipient to send a gift to.
*/
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
private val viewModel: GiftFlowViewModel by activityViewModel {
GiftFlowViewModel()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(
R.id.multiselect_container,
R.id.fragment_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
multiShareArgs = emptyList(),
title = R.string.GiftFlowRecipientSelectionFragment__choose_recipient,
forceDisableAddMessage = true,
selectSingleRecipient = true
)
@@ -79,6 +74,10 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
override fun exitFlow() = Unit
override fun navigateUp() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
@@ -25,6 +26,7 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
override val dataFlow: Flow<ArchiveRestoreProgressState> by lazy {
ArchiveRestoreProgress
.stateFlow
.onStart { ArchiveRestoreProgress.checkForStalledRestore() }
.filter {
it.restoreStatus != RestoreStatus.NONE && (it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED)
}

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