Compare commits

...

546 Commits

Author SHA1 Message Date
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
Michelle Tang a0577cd8a2 Bump version to 8.11.0 2026-05-14 13:41:16 -04:00
Michelle Tang 9438646814 Update translations and other static files. 2026-05-14 13:28:56 -04:00
Cody Henthorne 9dcf68581d Improve screen share capture dimension calculation and use remote config. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 4dd57460de Move a bunch of files into the network modules. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 6339b38dee Move RemoteConfigResponse. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 1ce41edc7f Move CertificateApi and RateLimitChallengeApi deps. 2026-05-14 13:23:16 -04:00
Michelle Tang 3087116618 Move hardcoded reg strings to strings.xml. 2026-05-14 13:23:16 -04:00
Michelle Tang 8fd2065253 Revert "Use adaptive bitmap for dynamic shortcut icons to remove white border."
This reverts commit b98452f7b3c99bdf5d4e02ba5129a63846ed563a.
2026-05-14 13:23:16 -04:00
Greyson Parrelli 09822d3ae9 More changes to fix reproducible build issues. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 10b0221e98 Add support for a better AEP character set. 2026-05-14 13:23:16 -04:00
Cody Henthorne db4def45f9 Reduce blocking calls in happy network call path. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 7dd6829bfa Automatically snooze megaphones after 3 days. 2026-05-14 13:23:16 -04:00
jeffrey-signal 0e40acfdaa Fix permissions screen button background elevation for medium/large layout. 2026-05-14 13:23:16 -04:00
Greyson Parrelli c2e8cec042 Upgrade to SQLCipher 4.16.0 2026-05-14 13:23:16 -04:00
Cody Henthorne 04d2b3b0fe Improve prekey fetch performance. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 64cdff4638 Remove support for END_SESSION flag. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 59b42ac546 Prompt permission for scheduled sends in media flow. 2026-05-14 13:23:16 -04:00
Cody Henthorne 2e9fd87b06 Reduce thrashing on multiple identity change events. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 0cf7705d4f Convert megaphone database and repository to kotlin. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 5bcbbdf339 React to availability of Play Services at app start. 2026-05-14 13:23:16 -04:00
jeffrey-signal a796316ad6 Add click handling for regV5 welcome screen terms button. 2026-05-14 13:23:16 -04:00
Greyson Parrelli 5655fcf973 Create new linkifier.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-05-12 10:05:38 -04:00
Alex Hart f4bd5fbe8b Add log line for ignored payment transcript. 2026-05-12 10:53:43 -03:00
Alex Hart fc448ecb59 Fix call screen crash when participant count drops during speaker view. 2026-05-12 10:47:28 -03:00
Michelle Tang c4e7841ea3 Fix missing chevron. 2026-05-12 09:46:19 -04:00
Cody Henthorne e248aee25c Improve quote query performance for conversation open. 2026-05-11 16:32:26 -04:00
Michelle Tang 7c9268e326 Turn on key transparency. 2026-05-11 16:32:26 -04:00
Alex Hart 8ffc2e7ab8 Handle SUBSCRIPTION_STATUS FetchLatest sync message. 2026-05-11 16:32:26 -04:00
andrew-signal b4404bb5b4 Bump to libsignal v0.94.0 2026-05-11 16:32:26 -04:00
Michelle Tang 155bba2f81 Expand touch area for key transparency bottom sheet. 2026-05-11 16:32:26 -04:00
adel-signal 639438b863 Update RingRTC version to v2.69.0 2026-05-11 16:32:26 -04:00
Alex Hart b374a90ffe Allow donations on linked devices. 2026-05-11 16:32:26 -04:00
Cody Henthorne d333503838 Migrate to new staging SVR2 enclave. 2026-05-11 16:32:26 -04:00
Alex Hart 2efc115410 Add intrumented testing and a small fix for nav from shortcuts. 2026-05-11 16:32:26 -04:00
Cody Henthorne 43a1c93961 Make network calling infrastructure more coroutine friendly. 2026-05-11 16:32:26 -04:00
Cody Henthorne 39529af4e9 Add screen share to 1:1 and group calling. 2026-05-11 16:32:24 -04:00
Cody Henthorne d1e2fc0423 Use trimmed video start for thumbnail generation. 2026-05-11 16:31:32 -04:00
Alex Hart 5e4865be73 Add a sync after rotating a subscriber id. 2026-05-11 16:31:32 -04:00
Cody Henthorne 5903c1bbf5 Update reproducible build dependencies. 2026-05-11 16:31:32 -04:00
Alex Hart 45da9fbfc0 Skip slow deviceheuristics for linked devices. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 0dacc4e8dc Ensure we use the correct index for starred messages. 2026-05-11 16:31:32 -04:00
Cody Henthorne 74935c963a Refresh thread snippet after view-once attachment is viewed.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-11 16:31:32 -04:00
Greyson Parrelli 02d245ac0c Manually draw location on google map. 2026-05-11 16:31:32 -04:00
Cody Henthorne e100ffbc14 Fix crash during group call peek with unknown members. 2026-05-11 16:31:32 -04:00
Cody Henthorne d4b3328151 Fix IllegalArgumentException crash when dismissing progress dialog. 2026-05-11 16:31:32 -04:00
Cody Henthorne 0e82a43be7 Cycle use REST fallback remote config to try without it again. 2026-05-11 16:31:32 -04:00
Alex Hart 0960c0dfea Skip processing backfill requests on linked devices. 2026-05-11 16:31:32 -04:00
jeffrey-signal 8503c49db0 Prevent input panel quote author name from overlapping the dismiss button. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 88b95ce6a5 Ignore lint error in keyboard layout. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 6a1d06486c Treat unparseable shortcut recipient ids as invalid shortcuts. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 824f0af00b Reject unknown share-target recipient ids. 2026-05-11 16:31:32 -04:00
Greyson Parrelli 03a6d8c12f Fix crash when handling malformed URI's. 2026-05-11 16:31:32 -04:00
Alex Hart f1b231ca38 Wire in inactive-primary websocket alert. 2026-05-11 16:31:31 -04:00
Greyson Parrelli dca4351b8b Update some gradle properties that are giving warnings. 2026-05-11 16:31:31 -04:00
andrew-signal b2a18f7202 Make chat list tap state slightly darker. 2026-05-11 16:31:31 -04:00
Michelle Tang d68e541ee6 Bump version to 8.10.2 2026-05-11 16:23:50 -04:00
Michelle Tang 1c6f093b4c Update baseline profile. 2026-05-11 16:18:44 -04:00
Michelle Tang b87486163d Update translations and other static files. 2026-05-11 15:59:35 -04:00
Michelle Tang 1ee341daac Turn off key transparency. 2026-05-11 15:24:36 -04:00
Cody Henthorne 6baebfe140 Fix PictureInPictureUiState not found crashes. 2026-05-08 15:35:40 -04:00
Cody Henthorne a6816df0e8 Fix username relost due to bad needs restore state. 2026-05-08 12:52:18 -04:00
jeffrey-signal 5b8c894512 Fix navigation from conversation settings to search and groups in common screens. 2026-05-08 11:51:35 -04:00
Greyson Parrelli 439760e773 Bump version to 8.10.1 2026-05-07 16:17:23 -04:00
Greyson Parrelli 7560896e2d Update baseline profile. 2026-05-07 16:16:42 -04:00
Greyson Parrelli fe18def67e Update translations and other static files. 2026-05-07 16:08:50 -04:00
Alex Hart 413962a093 Bypass single-pane scaffold for RTL.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-07 15:42:26 -04:00
Alex Hart e518eca9a1 Do not include SMS recipients in letter header query.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-07 16:26:13 -03:00
Greyson Parrelli b70322b5a6 Fix baseline profile build. 2026-05-07 09:09:30 -04:00
Cody Henthorne 047516c80b Fix missed update item wallpaper bubble background corner radius. 2026-05-06 14:13:41 -04:00
Greyson Parrelli 0a45b9b5e3 Bump version to 8.10.0 2026-05-06 13:21:37 -04:00
Greyson Parrelli 99b0061127 Update translations and other static files. 2026-05-06 13:21:07 -04:00
jeffrey-signal 7b11cc1676 Use GroupId navigation args for conversation settings instead of Parcelable. 2026-05-06 13:08:39 -04:00
jeffrey-signal 663e0a616e Fix message bubble caption exceeding the media width. 2026-05-06 13:08:39 -04:00
Alex Hart d05338cee0 Align sync message tracking with iOS. 2026-05-06 13:08:39 -04:00
Alex Hart ce294dbc0b Add mapping-based lazycolumn / lazyrow. 2026-05-06 13:08:39 -04:00
andrew-signal d0efd8d4b0 Bump to libsignal v0.93.2 2026-05-06 13:08:38 -04:00
Greyson Parrelli c8875b5ad1 Limit R8 threads to 1 to fix non-deterministic output. 2026-05-06 13:08:38 -04:00
jeffrey-signal 188458f772 Open chat tab conversation settings in the detail pane where available. 2026-05-06 13:08:38 -04:00
jeffrey-signal ed7fd10749 Split MainNavigationRouter into focused domain-specific routers. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 2ffbf09b1b Fix lint crash by switching static-ips to properties. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 799e57dbe9 Fix bug allowing concurrent execution of jobs in the same queue.
There was an issue where a higher priority job in the same queue would
become the new most eligible job, even if the current most eligible job
was actively running.
2026-05-06 13:08:38 -04:00
Greyson Parrelli 572c11ee6d Update to AGP 9.1.1 2026-05-06 13:08:38 -04:00
Alex Hart 4dd5a4ee53 Reduce Compose overhead on lower-end device.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Alex Hart 370fca3c89 Block screen recording during registration by applying FLAG_SECURE.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Cody Henthorne d91f130238 Update color and styling of release note update items. 2026-05-06 13:08:38 -04:00
Greyson Parrelli bb20432417 Fix verification message archive restore. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 8138ea5f8f Show error dialog when ChallengeRequired has empty challenges list in change number flow. 2026-05-06 13:08:38 -04:00
Greyson Parrelli f235aa0599 Fix audio message timestamp truncation when playback speed toggle is visible. 2026-05-06 13:08:38 -04:00
jeffrey-signal c7d719e983 Fix message text sometimes overlapping the footer text.
Resolves signalapp/Signal-Android#13580
2026-05-06 13:08:38 -04:00
adel-signal cf71d43a2f Update DRED duration remote config away from global 2026-05-06 13:08:38 -04:00
Greyson Parrelli 1e70e825a3 Skip session switchover events from non-ACI contacts during backup export. 2026-05-06 13:08:38 -04:00
Greyson Parrelli cce1979716 Catch ForegroundServiceStartNotAllowedException in AttachmentProgressService listener. 2026-05-06 13:08:38 -04:00
Greyson Parrelli ad7e9c0fd7 Skip unlock animation to reduce screen lock dismiss latency. 2026-05-06 13:08:38 -04:00
Greyson Parrelli bd3e1e8059 Catch IllegalArgumentException when setting precomputed text with stale params. 2026-05-06 13:08:38 -04:00
Greyson Parrelli adb9e2173f Reroute ISE to NoSessionException. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 958c6f451f Fix infinite spinner on RegistrationLockFragment when server rejects registration lock token. 2026-05-06 13:08:38 -04:00
Greyson Parrelli ab090236a1 Switch username scanner to use CameraScreen. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 23698dbc28 Switch linked device scanner to use CameraScreen. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 0542262c49 Improve error handling resiliance. 2026-05-06 13:08:38 -04:00
Greyson Parrelli e2d4ca9a4c Improve StorageSyncJob job data reliability. 2026-05-06 13:08:38 -04:00
Greyson Parrelli e54f3f501a Fix improper index usage on story queries. 2026-05-06 13:08:38 -04:00
Cody Henthorne 638d4997d1 Improve chat open performance when thread pool is saturated.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Jim Gustafson cbd05c4dff Update to RingRTC v2.68.1 2026-05-06 13:08:38 -04:00
andrew-signal ef396b5758 Bump libsignal to v0.93.1 2026-05-06 13:08:38 -04:00
Alex Hart 1d36ecafe1 Clean up back-pressed behavior which could result in an empty backstack and crash.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2026-05-06 13:08:38 -04:00
Alex Hart 07329c5b0d Migrate VerifyDisplayFragment to compose. 2026-05-06 13:08:38 -04:00
Alex Hart 7fc4ec3006 Migrate donation gateway sheet to compose. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 9e7477bbeb Ensure that story error query uses proper index. 2026-05-06 13:08:38 -04:00
Alex Hart c83054906b Upgrade compose bom.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Cody Henthorne 011dc3495f Fix FiatMoneyTests run on non-US locales. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 41b833e788 Improve deletion in all media screen. 2026-05-06 13:08:38 -04:00
Cody Henthorne e11f7225d3 Fix crash and subsequent retry after upload to archive fails length check. 2026-05-06 13:08:38 -04:00
Cody Henthorne bb261a3d85 Favor internal channel ids for recipients over externally created ones. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 116f702be6 Add Live Queries tab to Spinner. 2026-05-06 13:08:38 -04:00
Cody Henthorne 4d09776277 Improve db usage around ensuring custom notification channel stae. 2026-05-06 13:08:38 -04:00
jeffrey-signal f32184c27e Fix padding around incoming caller name. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 5fc037b324 Upgrade to SQLCipher 8.14.0 2026-05-06 13:08:38 -04:00
Cody Henthorne fc9d3e11e8 Only retrieve remote announcements during specific time window. 2026-05-06 13:08:38 -04:00
Cody Henthorne a951c7edfe Fix undownloaded voice note button UI bug. 2026-05-06 13:08:38 -04:00
Cody Henthorne 9d1714d452 Fix unique constraint crash when remapping recipients in name collision table. 2026-05-06 13:08:37 -04:00
Jordan Rose 9c2825f202 Consistently use core-util Hex utility class. 2026-05-06 13:08:37 -04:00
Greyson Parrelli a8969b34a4 Fix HUF currency formatting. 2026-05-06 13:08:37 -04:00
Cody Henthorne 1f59f3c2c4 Use correct wakelock for link device sync. 2026-05-06 13:08:37 -04:00
jeffrey-signal c6d91dce6e Convert ContextUtil to Kotlin. 2026-05-06 13:08:37 -04:00
jeffrey-signal 40c4633d41 Add utility method for resolving a FragmentActivity from Context. 2026-05-06 13:08:37 -04:00
Cody Henthorne edfe89683b Attempt to fix date headers overlaping scheduled messages. 2026-05-06 13:08:37 -04:00
Cody Henthorne cc3bedd154 Center release channel media bubbles in chat. 2026-05-06 13:08:37 -04:00
Cody Henthorne 56803a8850 Add internal preference to disable ANR induced crashing. 2026-05-06 13:08:37 -04:00
andrew-signal 2fdb712b38 Reattempt auth connection when network validated status changes. 2026-05-06 13:08:37 -04:00
Cody Henthorne 3d39045d1b Fix quickstart build failure. 2026-05-06 13:08:37 -04:00
Greyson Parrelli 90385b4e1c Fix flakey registration test. 2026-05-06 13:08:37 -04:00
Greyson Parrelli a02b66601c Remove support for END_SESSION message. 2026-05-06 13:08:37 -04:00
Alex Hart a83c57ff73 Use adaptive bitmap for dynamic shortcut icons to remove white border.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:37 -04:00
Alex Hart 3d063b38be Increase okhttp read/write timeouts for debug log uploads.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:37 -04:00
dependabot[bot] 03d20cb46a Update reproducible build dependencies. 2026-05-06 13:08:37 -04:00
Cody Henthorne 561186df90 Adjust auto-download checks. 2026-05-06 13:08:37 -04:00
Alex Hart fdcd21132c Update logic for processing qrs in CameraScreenViewModel. 2026-04-27 16:44:56 -04:00
Cody Henthorne 1043851423 Bump version to 8.9.1 2026-04-27 16:40:08 -04:00
Cody Henthorne 9bcbacc3d8 Update translations and other static files. 2026-04-27 16:40:08 -04:00
Cody Henthorne c2d7ee6926 Update release notes chat styling. 2026-04-27 16:39:59 -04:00
jeffrey-signal ceecacb47e Fix pull-to-refresh triggering when attempting to scroll up in contact search.
Resolves signalapp/Signal-Android#14742
2026-04-27 16:09:56 -04:00
Greyson Parrelli f4986273e4 Fix quote resolution failing for sent sync messages. 2026-04-27 13:34:52 -04:00
Greyson Parrelli 5f60adbe69 Update emoji_data.json 2026-04-27 13:34:52 -04:00
Alex Hart db6efeaf3d Revert "Migrate VerifyScanFragment to compose."
This reverts commit 9fa587b7e4.
2026-04-23 16:29:35 -03:00
Alex Hart 9b98b03971 Bump version to 8.9.0 2026-04-22 16:00:26 -03:00
Alex Hart dfbdf30535 Update baseline profile. 2026-04-22 15:49:27 -03:00
Alex Hart d567555047 Update translations and other static files. 2026-04-22 15:18:32 -03:00
Alex Hart 7658f6c36c Migrate ideal icon and copy. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 51bd2d51c6 Add missing remember keys for pane anchors and preferred width to fix stale layout on resize. 2026-04-22 15:12:47 -03:00
Jesse Weinstein a00978d96e Two trivial parameter renaming fixes
Closes signalapp/Signal-Android#14736
2026-04-22 15:12:47 -03:00
jeffrey-signal b700529c3b Fix stuck outgoing messages when there a no remaining linked devices. 2026-04-22 15:12:47 -03:00
jeffrey-signal 4051cf739c Fix crash when long-pressing a message when in conversation bubble mode. 2026-04-22 15:12:47 -03:00
Alex Hart 6031fc9113 Modify heuristic for split-pane determination. 2026-04-22 15:12:47 -03:00
Michelle Tang 454fe86dda Adjust spinner in phone number screen. 2026-04-22 15:12:47 -03:00
Michelle Tang 92927ec69b Clear existing key transparency data. 2026-04-22 15:12:47 -03:00
Alex Hart 9fa587b7e4 Migrate VerifyScanFragment to compose. 2026-04-22 15:12:47 -03:00
Jesse Weinstein 552361dff4 Remove unused StickyListHeaders dependency.
Closes signalapp/Signal-Android#14729
2026-04-22 15:12:47 -03:00
Greyson Parrelli 78a25a6186 Restrict setExactAndAllowWhileIdle to API >= 31. 2026-04-22 15:12:47 -03:00
Cody Henthorne 58fcc07578 Convert flakey MessageTable story instrumentation tests to unit tests. 2026-04-22 15:12:47 -03:00
Cody Henthorne 8cd92a400c Add debug and testing apis to Spinner. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 5d207932c9 Check instrumentation compilation in qa task. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 7c147982c4 Supply size to attachment form endpoint for archive backfill. 2026-04-22 15:12:47 -03:00
Alex Hart bde1a94122 Parallelize file deletion when turning off local backups. 2026-04-22 15:12:47 -03:00
Alex Hart 2b66d7485a Fix PiP on-back behavior.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-22 15:12:47 -03:00
Greyson Parrelli 017b902c3c Increase regV5 test coverage. 2026-04-22 15:12:47 -03:00
dependabot[bot] 357fbfa8aa Update reproducible build dependencies. 2026-04-22 15:12:47 -03:00
Michelle Tang 0ce667f4af Update enter aep for large screens. 2026-04-22 15:12:47 -03:00
jeffrey-signal c4d78243c8 Fix message backups education screen typo. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 51e12b2c76 Add flag to try different alarm for scheduled messages. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 4dea1d8aa1 Move storage service operations into the network module. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 89c645dea3 Create a network module. 2026-04-22 15:12:47 -03:00
Greyson Parrelli cd01d5f0b7 Enable remote mute for external users. 2026-04-17 17:00:09 -04:00
Michelle Tang 8730e28282 Update restore selection for large screens. 2026-04-17 16:31:59 -04:00
Greyson Parrelli 82046dd55f Add support for remote backup restore to regV5. 2026-04-17 15:54:24 -04:00
Cody Henthorne 76e30ab09f Add verified group title tracking and syncing. 2026-04-17 15:52:56 -04:00
Greyson Parrelli f680256f1d Remove range from copyright. 2026-04-17 15:26:52 -04:00
Michelle Tang da590a3241 Update verification code screen. 2026-04-17 15:26:52 -04:00
Alex Hart 91f73b473f Sanitize donations webview intents.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-17 15:26:52 -04:00
Alex Hart 53023517b3 Add authority check to VoiceCallShare. 2026-04-17 15:26:52 -04:00
Alex Hart 7f831e6806 Convert SafetyNumberReview dialogs to compose. 2026-04-17 15:26:51 -04:00
Alex Hart 77a18111e1 Convert search mediator to compose / viewmodel pattern. 2026-04-17 15:26:51 -04:00
Alex Hart 2a699a23dd Fix backup key verification megaphone ignoring snooze by using lastSeenTime.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-17 15:26:51 -04:00
Alex Hart 5643ffc1a9 Trust zero bottom inset when gesture navigation is detected on API <= 29. 2026-04-17 15:26:51 -04:00
Cody Henthorne 90207b7dd7 Convert handful of recipient/db heavy androidTests to regular unit tests. 2026-04-17 15:26:50 -04:00
andrew-signal 5b7f668251 Bump libsignal to v0.92.2 2026-04-17 15:26:50 -04:00
adel-signal 798bf3ec3e Update to RingRTC v2.68.0 2026-04-17 15:26:50 -04:00
Michelle Tang 1c77c9d3fb Check for valid phone number. 2026-04-17 15:26:50 -04:00
Michelle Tang dd52d78ee0 Update country picker for large screen. 2026-04-17 15:26:50 -04:00
Michelle Tang 4b1acca119 Scroll to initial country. 2026-04-17 15:26:49 -04:00
Michelle Tang 195fe60927 Update phone entry for large screen. 2026-04-17 15:26:49 -04:00
Cody Henthorne f427f31303 Improve group change defensive checks and update logic. 2026-04-17 15:26:49 -04:00
Greyson Parrelli fa19ed7ffc Use viewmodel entry scoping in regV5. 2026-04-17 15:26:49 -04:00
jeffrey-signal e5e99d4e03 Bump version to 8.8.2 2026-04-17 15:21:14 -04:00
jeffrey-signal 26d1a7ada7 Update baseline profile. 2026-04-17 15:00:00 -04:00
jeffrey-signal 5dd11e26e4 Update translations and other static files. 2026-04-17 14:53:54 -04:00
Alex Hart 9877b13c6e Add ability to launch into message backups checkout.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-04-17 12:33:52 -03:00
Greyson Parrelli d7d0fd3622 Rotate backup megaphone flag. 2026-04-17 10:09:12 -04:00
Sten Tijhuis 2439506c05 Update GitHub Actions versions and pin to commit SHAs.
Closes signalapp/Signal-Android#14715
2026-04-16 19:07:34 -04:00
jeffrey-signal 6088024f76 Revert "Use existing okhttp client + package checks for web apk."
This reverts commit df406633ff.
2026-04-16 19:01:09 -04:00
jeffrey-signal 9decd81cfc Bump version to 8.8.1 2026-04-16 14:25:08 -04:00
jeffrey-signal f27773a4e3 Update baseline profile. 2026-04-16 13:27:52 -04:00
jeffrey-signal 8d8c974a19 Update translations and other static files. 2026-04-16 13:20:10 -04:00
Cody Henthorne 1a3e81dcb0 Fix bad apostrophe escaping in new safety tip strings. 2026-04-16 13:08:59 -04:00
jeffrey-signal d5f85c0661 Bump version to 8.8.0 2026-04-15 20:18:12 -04:00
jeffrey-signal 91458f2702 Update baseline profile. 2026-04-15 20:18:12 -04:00
jeffrey-signal 6650ffc2c6 Update translations and other static files. 2026-04-15 20:17:59 -04:00
Cody Henthorne ab0102a372 Do not force-apply P2P group changes unless the change adds or removes us.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:14 -04:00
Cody Henthorne a797bbf850 Improve web socket behaviors around keep alive and shutdown. 2026-04-15 14:45:14 -04:00
Cody Henthorne 3804890265 Stop putting e164s into SignalProtocolAddress. 2026-04-15 14:45:14 -04:00
Greyson Parrelli fcdbf93626 Improve regV5 restore flows. 2026-04-15 14:45:14 -04:00
Michelle Tang f1b61f8f7e Add test dispatcher to phone number tests. 2026-04-15 14:45:14 -04:00
Michelle Tang ce582249ec Ask for permissions on the same screen. 2026-04-15 14:45:13 -04:00
Alex Hart b21a72153a Implement proper text-entry component for large screen media send flow. 2026-04-15 14:45:13 -04:00
jeffrey-signal 2a8bd20bb0 Fix sender name/label clipping in recycled conversation items.
Resolves signalapp/Signal-Android#14646
2026-04-15 14:45:13 -04:00
jeffrey-signal c30e3cc1b7 Disable group member labels while in message request state. 2026-04-15 14:45:13 -04:00
Greyson Parrelli 5fedd81921 Convert IndividualSendJob to kotlin. 2026-04-15 14:45:12 -04:00
jeffrey-signal 24069dc42e Fix self avatar in reaction bottom sheet. 2026-04-15 14:45:12 -04:00
Cody Henthorne ff15c8417a Clear upload spec when resume location is invalid in archive upload.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:12 -04:00
Greyson Parrelli cbf770d3ea Convert PushSendJob to kotlin. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 676ab1ab6f Merge SendJob into PushSendJob. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 9cc47942f2 Add process check to VoiceNotePlaybackService MediaSession access. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 45e6e06c01 Improve error handling for empty prekey bundles. 2026-04-15 14:45:11 -04:00
Michelle Tang d2243707b5 Update permissions UI. 2026-04-15 14:45:11 -04:00
Jesse Weinstein 48cd1c1da0 Convert Windows line endings to Unix format.
Closes signalapp/Signal-Android#14632
2026-04-15 14:45:11 -04:00
thomasboom 330a5aece2 Show "None" for media auto-download when all options are disabled.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>

Closes signalapp/Signal-Android#14678
2026-04-15 14:45:11 -04:00
Cody Henthorne 8c4f614d17 Allow reply action on notifications for messages with media attachments. 2026-04-15 14:45:11 -04:00
Leo Heitmann Ruiz f40bcb73fa Fix duplicate word in bug report template.
Closes signalapp/Signal-Android#14695
2026-04-15 14:45:10 -04:00
Jesse Weinstein 905a6f1a6b Fix typo in comment referencing ChatTypeSearchKey.
Closes signalapp/Signal-Android#14684
2026-04-15 14:45:10 -04:00
Jesse Weinstein 8f78471849 Remove the annimon.stream library.
Resolves #14719
2026-04-15 14:45:10 -04:00
Jesse Weinstein 82df20190d Remove all remaining usages of annimon.stream. 2026-04-15 14:45:10 -04:00
Greyson Parrelli 7f6e96a522 Check DownloadManager status to properly detect errors. 2026-04-15 14:45:09 -04:00
Greyson Parrelli eded335766 Fix potential crash when saving to fallback attachment folder.
Fixes #14720
2026-04-15 14:45:09 -04:00
Michelle Tang 7e4736969c Update country selection. 2026-04-15 14:45:09 -04:00
Greyson Parrelli 78940ffc17 Switch the labs plaintext export to share a single zip. 2026-04-15 14:45:09 -04:00
Jesse Weinstein 086883e565 Convert all the toList calls to collect(Collectors.toList)
Resolves #14718
2026-04-15 14:45:06 -04:00
Michelle Tang e9cdf0368e Update phone number UI. 2026-04-15 14:43:45 -04:00
Greyson Parrelli 7be273f461 Do not interrupt actively playing voice notes when locking. 2026-04-15 14:43:45 -04:00
Jesse Weinstein e6cbb0073c Remove more usages of annimon.stream.
Resolves #14717
2026-04-15 14:43:41 -04:00
Cody Henthorne 469421fcf3 Fix message request accepted to recipient from previous backup imports. 2026-04-15 14:43:12 -04:00
Greyson Parrelli 6d6d277277 Add share and forward entries to all media context menu. 2026-04-15 14:43:12 -04:00
jeffrey-signal 8a5faba985 Rename DragAndDrop -> DragToReorder to differentiate it from Android's drag-and-drop framework. 2026-04-15 14:43:12 -04:00
Cody Henthorne 7aadc208e1 Promptly remove terminated groups from shortcuts. 2026-04-15 14:43:11 -04:00
Greyson Parrelli 3c68e29679 Update image pasting to use ViewCompat.setOnReceiveContentListener. 2026-04-15 14:43:11 -04:00
Cody Henthorne 4756b8d70b Update conversation header and message request UI. 2026-04-15 14:43:11 -04:00
Alex Hart c2d927029a Add new ImageEditor compose component and wire in crop and drawing tools. 2026-04-13 16:25:00 -04:00
jeffrey-signal 629b96dd20 Fix wallpaper ANR regression while maintaining correct incoming message bubble colors. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 01705459cf Include registered state in support email. 2026-04-13 16:25:00 -04:00
Greyson Parrelli c449f72786 Allow internal shares as long as they originate from our process. 2026-04-13 16:25:00 -04:00
Alex Hart 773d6c36dc Add large-screen media send toolbars for image editing. 2026-04-13 16:25:00 -04:00
andrew-signal b4bfb67a44 Bump to libsignal v0.91.2 2026-04-13 16:25:00 -04:00
Michelle Tang 3165c854df Remove unused strings. 2026-04-13 16:25:00 -04:00
Cody Henthorne f5cb1b0efa Restrict telecom usage to API 37. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 179908fba6 Update registration error strings for SMS send failures. 2026-04-13 16:25:00 -04:00
Greyson Parrelli d6ec4bfbd3 Do not show getting started after local restore. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 237ac9f94a Use original message timestamp for saved attachment MediaStore metadata.
Fixes #14584
2026-04-13 16:25:00 -04:00
Greyson Parrelli 66f69854cf Expand whitespace character detection in StringUtil.isVisuallyEmpty.
Fixes #14470
2026-04-13 16:25:00 -04:00
Greyson Parrelli 8f47592fc0 Fix reconciliation error for thumbnails for quotes. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 3ea7bf77e0 Add release note validation check. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 2b67b1c44f Remove legacy ClassicOpenHelper. 2026-04-13 16:25:00 -04:00
Jesse Weinstein ebccc6db30 Remove a lot of dead code.
Resolves #14672
2026-04-13 16:25:00 -04:00
Greyson Parrelli 98d9b12438 Add null check in UriUtil. 2026-04-13 16:22:10 -04:00
Greyson Parrelli 5db8463c70 Improve content proxy domain matching. 2026-04-13 16:22:10 -04:00
Greyson Parrelli 813252989b Notify CallRequestController after cancel. 2026-04-13 16:22:09 -04:00
Greyson Parrelli 0319adbce4 Add null check to orientation unboxing. 2026-04-13 16:22:09 -04:00
Greyson Parrelli de584ccb7d Use message digest for faster comparison. 2026-04-13 16:22:09 -04:00
Greyson Parrelli bd89c7fc39 Add null check to pin message import. 2026-04-13 16:22:09 -04:00
Greyson Parrelli bef4bb40ca Do not swallow IOException during backup creation. 2026-04-13 16:22:08 -04:00
Jesse Weinstein b57d922cdf Remove use of annimon.stream in several places.
Resolves #14705
2026-04-13 16:22:08 -04:00
Greyson Parrelli 8c1cc03c6f Use non-deprecated libsignal network constructor. 2026-04-13 16:22:08 -04:00
jeffrey-signal f0109f3e6b Improve drag-to-reorder auto scroll behavior when dragging an item up the list. 2026-04-13 16:22:08 -04:00
Greyson Parrelli ed89f3a78e Don't connect to the websocket if we know we're unregistered. 2026-04-13 16:22:07 -04:00
Alex Hart faa6a1d3f0 Welcome screen polish. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 969635d942 Add redirect validation for link previews. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 7665ae1464 Verify payment address identity key against local identity store. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 9c18e3698e Apply CDN restrictions to quote attachments. 2026-04-13 16:22:07 -04:00
Greyson Parrelli df406633ff Use existing okhttp client + package checks for web apk. 2026-04-13 16:22:06 -04:00
Greyson Parrelli d121f9402b Improve quote validation. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 5310c19b99 Update isValidExternalUri. 2026-04-13 16:22:06 -04:00
Greyson Parrelli cd92feb2b7 Write profile avatars to temp file before renaming to final location. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 3b603f08ed Add defensive size check to stream read. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 281f062b29 Remove the DeprecatedPersistentBlobProvider. 2026-04-13 16:22:05 -04:00
jeffrey-signal b054a7eb76 Bump version to 8.7.3 2026-04-13 16:15:41 -04:00
jeffrey-signal 33b9c88ecd Update baseline profile. 2026-04-13 15:40:05 -04:00
jeffrey-signal 253d36ae13 Update translations and other static files. 2026-04-13 15:32:54 -04:00
Michelle Tang 8306f8ec5b Improve collapsed set selection. 2026-04-13 14:05:20 -04:00
Michelle Tang 69b6d7ef9a Fix missing gallery photos.
Resolves signalapp/Signal-Android#14709
2026-04-13 14:05:00 -04:00
Greyson Parrelli aeeba3d2df Fix NPE when there's no retryafter duration. 2026-04-12 14:20:32 -04:00
Greyson Parrelli dfd2f7baf9 Bump version to 8.7.2 2026-04-09 22:44:57 -04:00
Greyson Parrelli 5de17a971d Update translations and other static files. 2026-04-09 22:44:34 -04:00
Greyson Parrelli 001896d244 Fix image transition animation. 2026-04-09 22:02:46 -04:00
Michelle Tang 1844b128e1 Use server timestamp for admin delete. 2026-04-09 17:17:55 -04:00
Michelle Tang 08623cc0c4 Use proper sender for early messages. 2026-04-09 15:44:35 -04:00
Greyson Parrelli f93a948169 Fix PIN creation loop during registration. 2026-04-09 13:48:46 -04:00
Cody Henthorne 76476191be Show better error ux for group calls you cannot start. 2026-04-09 10:27:44 -04:00
Greyson Parrelli d00bb28ee4 Bump version to 8.7.1 2026-04-08 22:10:47 -04:00
Greyson Parrelli 453e5bede7 Fix bad bubble tints for chats with wallpapers. 2026-04-08 22:04:17 -04:00
Michelle Tang c7c108bd77 Fix missing gallery photos. 2026-04-08 19:40:13 -04:00
Greyson Parrelli fb81574d35 Bump version to 8.7.0 2026-04-08 16:39:21 -04:00
Greyson Parrelli e6d3de091c Update translations and other static files. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 99b8a6020d Fix flaky registration tests. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 88b21b6113 Improve validator testing. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 256ee9b1aa Delete unused apns database. 2026-04-08 16:39:21 -04:00
Alex Hart e2feaaf74c Add initial working E2E flow for MediaSendV3. 2026-04-08 16:39:21 -04:00
jeffrey-signal 17def87c17 Fix compose preview rendering when using Emojifier. 2026-04-08 16:39:20 -04:00
Alex Hart d90e9919ae Adaptive welcome screen with compact, medium, and large layouts. 2026-04-08 16:39:20 -04:00
Jim Gustafson 38baf17938 Update to RingRTC v2.67.2 2026-04-08 16:39:20 -04:00
scueZ 3f7707985f Skip confusing delete dialog body text in Note to Self.
Resolves #14708
2026-04-08 16:39:20 -04:00
jeffrey-signal a61072b249 Member label performance optimizations. 2026-04-08 16:39:20 -04:00
jeffrey-signal 80ff64ddd3 Prevent unregistered accounts from showing in group call participants. 2026-04-08 16:39:20 -04:00
Cody Henthorne 95c0467bda Show unanswered outgoing calls as unanswered. 2026-04-08 16:39:20 -04:00
Greyson Parrelli ff88d259fd Use long for key id. 2026-04-08 16:39:20 -04:00
Alex Hart 6e747019d4 Fix NPE in toPendingOneTimeDonation when waitForAuth is null.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 16:29:15 -04:00
Greyson Parrelli 9e7a40a63d Extend proper base activity. 2026-04-08 16:29:14 -04:00
Greyson Parrelli 38eed43046 Add long-press context menu in all media screen. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 4c76cb682e Give a media/no-media choice in labs plaintext export. 2026-04-08 15:50:43 -04:00
Michelle Tang c47adb7482 Update padding sizes of update items. 2026-04-08 15:50:43 -04:00
Alex Hart 3c2ccef9a8 Fix upgrade card text color not adapting to dark mode.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 15:50:43 -04:00
jeffrey-signal fb0c4757f2 Fix media count indicator button colors so they match the chat color. 2026-04-08 15:50:43 -04:00
jeffrey-signal b8b9a632b5 Always prefetch wallpaper before opening a conversation. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 9b4a13a491 Potential fix to configuration cache issues with translations. 2026-04-08 15:50:43 -04:00
Cody Henthorne 1cdd49721d Add logging around rotate storage id failures during storage sync. 2026-04-08 15:50:43 -04:00
Cody Henthorne 8b895738c0 Update telecom to 1.1.0-alpha04 2026-04-07 10:05:42 -04:00
Cody Henthorne 6ab3cd3390 Don't show terminated groups after storage service restore. 2026-04-07 09:39:06 -04:00
Alex Hart 11c8a726ec Increment localPlaintextExport flag to lock version. 2026-04-06 16:47:01 -04:00
Alex Hart 264447a6d9 Add breakpoint helper and expand device previews.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang a7bb2831f8 Fix possible misuse of mp4 sanitizer. 2026-04-06 16:47:01 -04:00
Greyson Parrelli e05586a1c9 Convert RegistrationNetworkResult to RequestResult. 2026-04-06 16:47:01 -04:00
Greyson Parrelli 0e8dedf4d0 App ability to regV5 in the main app, behind compile flag. 2026-04-06 16:47:01 -04:00
Michelle Tang 0e11a1fe3e Add logs for voice note proximity. 2026-04-06 16:47:01 -04:00
adel-signal f1ebd2dc81 Add CallingAssetsDownloadJob to app startup to init calling assets
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang 8ea90c8a43 Cancel checking for messages on foreground. 2026-04-06 16:46:05 -04:00
Michelle Tang 6456dcf657 Fix potential edit message race condition. 2026-04-06 16:46:05 -04:00
Greyson Parrelli bb151c91e9 Add basic infra for regV5 local restore. 2026-04-06 16:46:05 -04:00
Greyson Parrelli ce6f39ae68 Update to the new attachment upload form libsignal method. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 58e8ea08c2 Bump to libsignal v0.91.0 2026-04-06 16:46:05 -04:00
Michelle Tang 4dd74d9ab4 Fix collapsed tests. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 3ef3a516b3 Prevent repeated storage-full notifications during backup.
When remote backup storage is full, hundreds of CopyAttachmentToArchiveJob
instances each independently call markOutOfRemoteStorageSpaceError(), which
re-posts the notification every time. Even though the notification ID is the
same, each call re-alerts the user with sound and vibration.

Guard markOutOfRemoteStorageSpaceError() to only post the notification once
by checking the flag before proceeding, and move the flag-set before the
notification post to prevent races. Also add an early exit in
CopyAttachmentToArchiveJob to skip the network quota check when already
marked as out of storage space.
2026-04-06 16:46:05 -04:00
Greyson Parrelli 518a81c7fa Do not start a call while one is in progress. 2026-04-06 16:46:05 -04:00
Michelle Tang f81325e7ca Pause voice notes when joining calls. 2026-04-06 16:46:05 -04:00
Greyson Parrelli cc847cb229 Fix potential glide lifecycle issue with transition animation. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7320a0ef46 Guard against potential crash when reacting to a message. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7c45686440 Fix potential missing ACI crash in verify screen. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 8b5b83e974 Remove unnecessary transaction in LocalMetricsDatabase.
There was a native crash associated with it, unclear the cause, but
maybe this will help.
2026-04-06 16:46:05 -04:00
Michelle Tang a4a3861398 Disable proximity sensor when using headsets for voice notes. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 01bdaaea84 Improve ANR stack trace perf. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 1f02fba696 Include captcha info in support email template. 2026-04-06 16:46:04 -04:00
2029 changed files with 124050 additions and 60070 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ body:
label: "Guidelines"
description: "Search issues here: https://github.com/signalapp/Signal-Android/issues/?q=is%3Aissue+"
options:
- label: I have searched searched open and closed issues for duplicates
- label: I have searched open and closed issues for duplicates
required: true
- label: I am submitting a bug report for existing functionality that does not work as intended
required: true
+27
View File
@@ -0,0 +1,27 @@
version: 2
updates:
# Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
# Dependabot will update both the SHA and the inline version comment (e.g. # v6)
# while leaving any extra documentation comments intact.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
commit-message:
prefix: "ci"
groups:
actions:
patterns:
- "actions/*"
gradle-actions:
patterns:
- "gradle/*"
peter-evans:
patterns:
- "peter-evans/*"
usefulness:
patterns:
- "usefulness/*"
+23 -6
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- 'main'
- '7.**'
- '8.**'
permissions:
contents: read # to fetch code (actions/checkout)
@@ -16,26 +16,43 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
- name: set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
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 }}
- name: Build with Gradle
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 qa
- name: Archive reports for failed build
if: ${{ failure() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: reports
path: '*/build/reports'
+28 -11
View File
@@ -14,29 +14,41 @@ jobs:
assemble-base:
if: ${{ github.repository != 'signalapp/Signal-Android' }}
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
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 }}"
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Cache base apk
id: cache-base
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
key: diffuse-${{ github.event.pull_request.base.sha }}
@@ -49,7 +61,8 @@ 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@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
clean: 'false'
@@ -61,18 +74,21 @@ jobs:
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-new.apk
- id: diffuse
uses: usefulness/diffuse-action@v1
uses: usefulness/diffuse-action@41995fe8ff6be0a8847e63bdc5a4679c704b455c # v1
# gh api repos/usefulness/diffuse-action/commits/v1 --jq '.sha'
with:
old-file-path: diffuse-base.apk
new-file-path: diffuse-new.apk
- uses: peter-evans/find-comment@v2
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
# gh api repos/peter-evans/find-comment/commits/v4 --jq '.sha'
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: Diffuse output
- uses: peter-evans/create-or-update-comment@v3
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
# gh api repos/peter-evans/create-or-update-comment/commits/v5 --jq '.sha'
with:
body: |
Diffuse output:
@@ -83,7 +99,8 @@ jobs:
issue-number: ${{ github.event.pull_request.number }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: diffuse-output
path: ${{ steps.diffuse.outputs.diff-file }}
+2 -1
View File
@@ -11,7 +11,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
cd reproducible-builds
+2 -1
View File
@@ -14,7 +14,8 @@ jobs:
actions: write
steps:
- uses: actions/stale@v10
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
days-before-close: 7
+2 -2
View File
@@ -1,6 +1,6 @@
# Signal Android
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely.
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/4G/5G) to communicate securely.
Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected.
@@ -63,7 +63,7 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2025 Signal Messenger, LLC
Copyright 2013 Signal Messenger, LLC
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
+173 -127
View File
@@ -1,7 +1,10 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.api.dsl.ManagedVirtualDevice
import com.android.build.api.artifact.ArtifactTransformationRequest
import com.android.build.api.artifact.SingleArtifact
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@@ -10,7 +13,6 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.ktlint)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
@@ -22,21 +24,22 @@ plugins {
id("licenses")
}
apply(from = "static-ips.gradle.kts")
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1675
val canonicalVersionName = "8.6.2"
val canonicalVersionCode = 1702
val canonicalVersionName = "8.14.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
val possibleHotfixVersions = (0 until maxHotfixVersions).toList().filter { it % 10 != 0 }
val debugKeystorePropertiesProvider = providers.of(PropertiesFileValueSource::class.java) {
val debugKeystorePropertiesProvider: Provider<Properties> = providers.of(PropertiesFileValueSource::class.java) {
parameters.file.set(rootProject.layout.projectDirectory.file("keystore.debug.properties"))
}
val languagesProvider = providers.of(LanguageListValueSource::class.java) {
val languagesProvider: Provider<List<String>> = providers.of(LanguageListValueSource::class.java) {
parameters.resDir.set(layout.projectDirectory.dir("src/main/res"))
}
@@ -53,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",
@@ -65,13 +73,11 @@ val selectableVariants = listOf(
"playProdMocked",
"playProdNonMinifiedMocked",
"playProdBenchmark",
"playProdInstrumentation",
"playProdRelease",
"playStagingDebug",
"playStagingCanary",
"playStagingSpinner",
"playStagingPerf",
"playStagingInstrumentation",
"playStagingRelease",
"playProdQuickstart",
"playStagingQuickstart",
@@ -81,6 +87,27 @@ val selectableVariants = listOf(
"githubProdRelease"
)
// Wire 5.x iterates Android source sets and expects matching Kotlin source sets.
// AGP 9.0's built-in Kotlin doesn't create all source sets automatically.
val kotlinExt = extensions.getByName("kotlin") as KotlinAndroidProjectExtension
android.sourceSets.all {
kotlinExt.sourceSets.findByName(name) ?: kotlinExt.sourceSets.create(name)
}
// AGP 9.0's built-in Kotlin doesn't pick up extra java.srcDir entries from Android
// source sets, so add shared dirs directly to the relevant Kotlin compile tasks.
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach {
val isTestTask = name.contains("UnitTest") || name.contains("AndroidTest")
if (isTestTask) {
source("$projectDir/src/testShared")
}
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
source("$projectDir/src/benchmarkShared/java")
}
if (isTestTask && name.contains("AndroidTest")) {
source("$projectDir/src/benchmarkShared/java")
}
}
wire {
kotlin {
javaInterop = true
@@ -94,8 +121,6 @@ wire {
srcDir("${project.rootDir}/lib/libsignal-service/src/main/protowire")
srcDir("${project.rootDir}/lib/archive/src/main/protowire")
}
// Handled by libsignal
prune("signalservice.DecryptionErrorMessage")
}
ktlint {
@@ -106,21 +131,14 @@ android {
namespace = "org.thoughtcrime.securesms"
buildToolsVersion = libs.versions.buildTools.get()
compileSdkVersion = libs.versions.compileSdk.get()
compileSdkVersion(libs.versions.compileSdk.get())
ndkVersion = libs.versions.ndk.get()
flavorDimensions += listOf("distribution", "environment")
testBuildType = "instrumentation"
android.bundle.language.enableSplit = false
kotlinOptions {
jvmTarget = libs.versions.kotlinJvmTarget.get()
freeCompilerArgs = listOf("-Xjvm-default=all")
suppressWarnings = true
}
debugKeystorePropertiesProvider.orNull?.let { properties ->
debugKeystorePropertiesProvider.get().takeIf { it.isNotEmpty() }?.let { properties ->
signingConfigs.getByName("debug").apply {
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
storePassword = properties.getProperty("storePassword")
@@ -137,8 +155,8 @@ android {
}
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel3api30") {
localDevices {
create("pixel3api30") {
device = "Pixel 3"
apiLevel = 30
systemImageSource = "google-atd"
@@ -155,6 +173,7 @@ android {
getByName("androidTest") {
java.srcDir("$projectDir/src/testShared")
java.srcDir("$projectDir/src/benchmarkShared/java")
}
}
@@ -195,10 +214,6 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
defaultConfig {
if (currentHotfixVersion >= maxHotfixVersions) {
throw AssertionError("Hotfix version offset is too large!")
@@ -206,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()
@@ -240,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\"")
@@ -263,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")
@@ -279,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"
}
@@ -291,7 +313,7 @@ android {
isDefault = true
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard/proguard-firebase-messaging.pro",
"proguard/proguard-google-play-services.pro",
"proguard/proguard-jackson.pro",
@@ -308,6 +330,7 @@ android {
"proguard/proguard-retrolambda.pro",
"proguard/proguard-okhttp.pro",
"proguard/proguard-ez-vcard.pro",
"proguard/proguard-dnsjava.pro",
"proguard/proguard.cfg"
)
testProguardFiles(
@@ -327,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
@@ -453,8 +465,8 @@ android {
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
@@ -480,70 +492,6 @@ android {
lintConfig = rootProject.file("lint.xml")
}
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
variant.packaging.jniLibs.useLegacyPackaging.set(true)
}
// Version overrides
if (variant.name.contains("nightly", ignoreCase = true)) {
var tag = getNightlyTagForCurrentCommit()
if (!tag.isNullOrEmpty()) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
val nightlyBuffer = (5 * maxHotfixVersions)
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
}
}
onVariants(selector().withBuildType("quickstart")) { variant ->
val environment = variant.flavorName?.let { name ->
when {
name.contains("staging", ignoreCase = true) -> "staging"
name.contains("prod", ignoreCase = true) -> "prod"
else -> "prod"
}
} ?: "prod"
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
if (quickstartCredentialsDir != null) {
inputDir.set(File(quickstartCredentialsDir))
}
filePrefix.set("${environment}_")
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
onVariants(selector().withBuildType("benchmark")) { variant ->
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
if (benchmarkBackupFile != null) {
inputFile.set(File(benchmarkBackupFile))
}
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
}
val releaseDir = "$projectDir/src/release/java"
val debugDir = "$projectDir/src/debug/java"
@@ -565,15 +513,83 @@ android {
manifest.srcFile("$projectDir/src/benchmarkShared/AndroidManifest.xml")
}
}
}
applicationVariants.configureEach {
outputs.configureEach {
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
val fileVersionName = versionName.substringBefore(" |")
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
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
val renameTask = tasks.register<RenameApkTask>("renameApk${variant.name.replaceFirstChar { it.uppercase() }}")
val renameRequest = variant.artifacts.use(renameTask)
.wiredWithDirectories(RenameApkTask::apkFolder, RenameApkTask::outFolder)
.toTransformMany(SingleArtifact.APK)
renameTask.configure {
transformationRequest.set(renameRequest)
}
// 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.
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
variant.packaging.jniLibs.useLegacyPackaging.set(true)
}
// Version overrides
if (variant.name.contains("nightly", ignoreCase = true)) {
var tag = getNightlyTagForCurrentCommit()
if (!tag.isNullOrEmpty()) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
val nightlyBuffer = (5 * maxHotfixVersions)
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
}
}
onVariants(selector().withBuildType("quickstart")) { variant ->
val environment = variant.flavorName?.let { name ->
when {
name.contains("staging", ignoreCase = true) -> "staging"
name.contains("prod", ignoreCase = true) -> "prod"
else -> "prod"
}
} ?: "prod"
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
if (quickstartCredentialsDir != null) {
inputDir.set(File(quickstartCredentialsDir))
}
filePrefix.set("${environment}_")
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
onVariants(selector().withBuildType("benchmark")) { variant ->
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
if (benchmarkBackupFile != null) {
inputFile.set(File(benchmarkBackupFile))
}
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
}
baselineProfile {
@@ -590,6 +606,14 @@ baselineProfile {
dexLayoutOptimization = false
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(libs.versions.kotlinJvmTarget.get())
freeCompilerArgs.addAll("-Xjvm-default=all")
suppressWarnings = true
}
}
dependencies {
lintChecks(project(":lintchecks"))
ktlintRuleset(libs.ktlint.twitter.compose)
@@ -597,6 +621,7 @@ dependencies {
implementation(project(":lib:archive"))
implementation(project(":lib:libsignal-service"))
implementation(project(":lib:network"))
implementation(project(":lib:paging"))
implementation(project(":core:util"))
implementation(project(":lib:glide"))
@@ -618,11 +643,7 @@ dependencies {
implementation(project(":lib:apng"))
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.appcompat) {
version {
strictly("1.6.1")
}
}
implementation(libs.androidx.appcompat)
implementation(libs.androidx.window.window)
implementation(libs.androidx.window.java)
implementation(libs.androidx.recyclerview)
@@ -671,6 +692,7 @@ dependencies {
implementation(libs.google.play.services.maps)
implementation(libs.google.play.services.auth)
implementation(libs.google.signin)
implementation(libs.androidx.media)
implementation(libs.bundles.media3)
implementation(libs.conscrypt.android)
implementation(libs.signal.aesgcmprovider)
@@ -678,7 +700,6 @@ dependencies {
implementation(libs.mobilecoin)
implementation(libs.signal.ringrtc)
implementation(libs.leolin.shortcutbadger)
implementation(libs.emilsjolander.stickylistheaders)
implementation(libs.glide.glide)
implementation(libs.roundedimageview)
implementation(libs.materialish.progress)
@@ -689,10 +710,6 @@ dependencies {
implementation(libs.subsampling.scale.image.view) {
exclude(group = "com.android.support", module = "support-annotations")
}
implementation(libs.android.tooltips) {
exclude(group = "com.android.support", module = "appcompat-v7")
}
implementation(libs.stream)
implementation(libs.lottie)
implementation(libs.lottie.compose)
implementation(libs.signal.android.database.sqlcipher)
@@ -704,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)
@@ -725,7 +743,7 @@ dependencies {
"canaryImplementation"(libs.square.leakcanary)
"instrumentationImplementation"(libs.androidx.fragment.testing) {
androidTestImplementation(libs.androidx.fragment.testing) {
exclude(group = "androidx.test", module = "core")
}
@@ -747,6 +765,7 @@ dependencies {
}
testImplementation(testLibs.conscrypt.openjdk.uber)
testImplementation(testLibs.mockk)
testImplementation(testFixtures(project(":core:ui")))
testImplementation(testFixtures(project(":lib:libsignal-service")))
testImplementation(testLibs.espresso.core)
testImplementation(testLibs.kotlinx.coroutines.test)
@@ -861,7 +880,7 @@ abstract class LanguageListValueSource : ValueSource<List<String>, LanguageListV
}
}
abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFileValueSource.Params> {
abstract class PropertiesFileValueSource : ValueSource<Properties, PropertiesFileValueSource.Params> {
interface Params : ValueSourceParameters {
@get:InputFile
@get:Optional
@@ -869,9 +888,9 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
val file: RegularFileProperty
}
override fun obtain(): Properties? {
override fun obtain(): Properties {
val f: File = parameters.file.asFile.get()
if (!f.exists()) return null
if (!f.exists()) return Properties()
return Properties().apply {
f.inputStream().use { load(it) }
@@ -941,3 +960,30 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
backupFile.copyTo(dest.resolve("backup.binproto"), overwrite = true)
}
}
abstract class RenameApkTask : DefaultTask() {
@get:InputFiles
abstract val apkFolder: DirectoryProperty
@get:OutputDirectory
abstract val outFolder: DirectoryProperty
@get:Internal
abstract val transformationRequest: Property<ArtifactTransformationRequest<RenameApkTask>>
@TaskAction
fun rename() {
transformationRequest.get().submit(this) { artifact ->
val originalFile = File(artifact.outputFile)
val versionName = artifact.versionName?.substringBefore(" |")
val newName = if (!versionName.isNullOrEmpty()) {
originalFile.name.replace(".apk", "-$versionName.apk")
} else {
originalFile.name
}
val newFile = File(outFolder.get().asFile, newName)
originalFile.copyTo(newFile, overwrite = true)
newFile
}
}
}
+33 -1110
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# dnsjava references desktop/server-only classes that are absent on Android.
-dontwarn com.sun.jna.**
-dontwarn javax.naming.**
-dontwarn lombok.Generated
+9
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*(**);
}
@@ -16,6 +17,14 @@
-keep class androidx.window.** { *; }
# Workaround for R8 non-determinism in AGP 9.x. R8 inconsistently keeps or strips
# the Signature attribute on this Kotlin lambda subclass of the generic
# LottieValueCallback, causing intermittent dex byte differences. Explicitly
# keeping the class stabilizes R8's attribute decisions.
-keep class com.airbnb.lottie.compose.LottieDynamicPropertiesKt$toValueCallback$1 {
*;
}
-keepclassmembers class * extends androidx.constraintlayout.motion.widget.Key {
public <init>();
}
@@ -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))
@@ -16,6 +16,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
@@ -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(),
@@ -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
@@ -1,384 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
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.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.ParentStoryId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.UUID
import java.util.concurrent.TimeUnit
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class MmsTableTest_stories {
private lateinit var mms: MessageTable
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
private lateinit var myStory: Recipient
private lateinit var recipients: List<RecipientId>
private lateinit var releaseChannelRecipient: Recipient
@Before
fun setUp() {
mms = SignalDatabase.messages
mms.deleteAllThreads()
SignalStore.account.setAci(localAci)
SignalStore.account.setPni(localPni)
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
releaseChannelRecipient = Recipient.resolved(SignalDatabase.recipients.insertReleaseChannelRecipient())
SignalStore.releaseChannel.setReleaseChannelRecipientId(releaseChannelRecipient.id)
}
@Test
fun givenNoStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectAnEmptyList() {
// WHEN
val result = mms.getOrderedStoryRecipientsAndIds(false)
// THEN
assertEquals(0, result.size)
}
@Test
fun givenOneOutgoingAndOneIncomingStory_whenIGetOrderedStoryRecipientsAndIds_thenIExpectIncomingThenOutgoing() {
// GIVEN
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
val sender = recipients[0]
MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 1,
storyType = StoryType.STORY_WITH_REPLIES,
threadId = threadId
)
MmsHelper.insert(
IncomingMessage(
type = MessageType.NORMAL,
from = sender,
sentTimeMillis = 2,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
)
// WHEN
val result = mms.getOrderedStoryRecipientsAndIds(false)
// THEN
assertEquals(listOf(sender.toLong(), myStory.id.toLong()), result.map { it.recipientId.toLong() })
}
@Test
fun givenAStory_whenISetIncomingStoryMessageViewed_thenIExpectASetReceiptTimestamp() {
// GIVEN
val sender = recipients[0]
val messageId = MmsHelper.insert(
IncomingMessage(
type = MessageType.NORMAL,
from = sender,
sentTimeMillis = 2,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
val messageBeforeMark = SignalDatabase.messages.getMessageRecord(messageId)
assertFalse(messageBeforeMark.incomingStoryViewedAtTimestamp > 0)
// WHEN
SignalDatabase.messages.setIncomingMessageViewed(messageId)
// THEN
val messageAfterMark = SignalDatabase.messages.getMessageRecord(messageId)
assertTrue(messageAfterMark.incomingStoryViewedAtTimestamp > 0)
}
@Ignore
@Test
fun given5ViewedStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectLatestViewedFirst() {
// GIVEN
val messageIds = recipients.take(5).map {
MmsHelper.insert(
IncomingMessage(
type = MessageType.NORMAL,
from = it,
sentTimeMillis = 2,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
}
val randomizedOrderedIds = messageIds.shuffled()
randomizedOrderedIds.forEach {
SignalDatabase.messages.setIncomingMessageViewed(it)
Thread.sleep(5)
}
// WHEN
val result = SignalDatabase.messages.getOrderedStoryRecipientsAndIds(false)
val resultOrderedIds = result.map { it.messageId }
// THEN
assertEquals(randomizedOrderedIds.reversed(), resultOrderedIds)
}
@Test
fun given15Stories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectUnviewedThenInterspersedViewedAndSelfSendsAllDescending() {
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
val unviewedIds: List<Long> = (0 until 5).map {
Thread.sleep(5)
MmsHelper.insert(
IncomingMessage(
type = MessageType.NORMAL,
from = recipients[it],
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
}
val viewedIds: List<Long> = (0 until 5).map {
Thread.sleep(5)
MmsHelper.insert(
IncomingMessage(
type = MessageType.NORMAL,
from = recipients[it],
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
}
val interspersedIds: List<Long> = (0 until 10).map {
Thread.sleep(5)
if (it % 2 == 0) {
SignalDatabase.messages.setIncomingMessageViewed(viewedIds[it / 2])
viewedIds[it / 2]
} else {
MmsHelper.insert(
recipient = myStory,
sentTimeMillis = System.currentTimeMillis(),
storyType = StoryType.STORY_WITH_REPLIES,
threadId = myStoryThread
)
}
}
val result = SignalDatabase.messages.getOrderedStoryRecipientsAndIds(false)
val resultOrderedIds = result.map { it.messageId }
assertEquals(unviewedIds.reversed() + interspersedIds.reversed(), resultOrderedIds)
}
@Test
fun givenNoStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
// THEN
assertFalse(result)
}
@Test
fun givenNoOutgoingStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
// GIVEN
MmsHelper.insert(
IncomingMessage(
type = MessageType.NORMAL,
from = recipients[0],
sentTimeMillis = 200,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
)
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
// THEN
assertFalse(result)
}
@Test
fun givenOutgoingStoryExistsForRecipientAndTime_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectTrue() {
// GIVEN
MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 200,
storyType = StoryType.STORY_WITH_REPLIES
)
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(myStory.id, 200)
// THEN
assertTrue(result)
}
@Test
fun givenAGroupStoryWithNoReplies_whenICheckHasSelfReplyInGroupStory_thenIExpectFalse() {
// GIVEN
val groupStoryId = MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 200,
storyType = StoryType.STORY_WITH_REPLIES
)
// WHEN
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertFalse(result)
}
@Ignore
@Test
fun givenAGroupStoryWithAReplyFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectTrue() {
// GIVEN
val groupStoryId = MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 200,
storyType = StoryType.STORY_WITH_REPLIES,
threadId = -1L
)
MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 201,
storyType = StoryType.NONE,
parentStoryId = ParentStoryId.GroupReply(groupStoryId)
)
// WHEN
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertTrue(result)
}
@Test
fun givenAGroupStoryWithAReactionFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectTrue() {
// GIVEN
val groupStoryId = MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 200,
storyType = StoryType.STORY_WITH_REPLIES
)
MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 201,
storyType = StoryType.NONE,
parentStoryId = ParentStoryId.GroupReply(groupStoryId),
isStoryReaction = true
)
// WHEN
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertTrue(result)
}
@Test
fun givenAGroupStoryWithAReplyFromSomeoneElse_whenICheckHasSelfReplyInGroupStory_thenIExpectFalse() {
// GIVEN
val groupStoryId = MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 200,
storyType = StoryType.STORY_WITH_REPLIES
)
MmsHelper.insert(
IncomingMessage(
type = MessageType.NORMAL,
from = myStory.id,
sentTimeMillis = 201,
serverTimeMillis = 201,
receivedTimeMillis = 202,
parentStoryId = ParentStoryId.GroupReply(groupStoryId)
),
SignalDatabase.threads.getOrCreateThreadIdFor(myStory, ThreadTable.DistributionTypes.DEFAULT)
)
// WHEN
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
// THEN
assertFalse(result)
}
@Test
fun givenNotViewedOnboardingAndOnlyStoryIsOnboardingAndAdded2DaysAgo_whenIGetOldestStoryTimestamp_thenIExpectNull() {
// GIVEN
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(releaseChannelRecipient)
MmsHelper.insert(
recipient = releaseChannelRecipient,
sentTimeMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2),
storyType = StoryType.STORY_WITH_REPLIES,
threadId = threadId
)
// WHEN
val oldestTimestamp = SignalDatabase.messages.getOldestStorySendTimestamp(false)
// THEN
assertNull(oldestTimestamp)
}
@Test
fun givenViewedOnboardingAndOnlyStoryIsOnboardingAndAdded2DaysAgo_whenIGetOldestStoryTimestamp_thenIExpectNotNull() {
// GIVEN
val expected = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(releaseChannelRecipient)
MmsHelper.insert(
recipient = releaseChannelRecipient,
sentTimeMillis = expected,
storyType = StoryType.STORY_WITH_REPLIES,
threadId = threadId
)
// WHEN
val oldestTimestamp = SignalDatabase.messages.getOldestStorySendTimestamp(true)
// THEN
assertEquals(expected, oldestTimestamp)
}
}
@@ -4,17 +4,17 @@ import android.app.Application
import io.mockk.mockk
import io.mockk.spyk
import org.signal.core.util.billing.BillingApi
import org.signal.network.api.ArchiveApi
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
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.archive.ArchiveApi
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!!
}
@@ -19,6 +19,8 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -27,8 +29,6 @@ import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
class BackupDeleteJobTest {
@@ -27,6 +27,8 @@ import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -42,8 +44,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
@@ -0,0 +1,843 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.io.ByteArrayOutputStream
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
* through [MainNavigationViewModel].
*/
@RunWith(AndroidJUnit4::class)
class MainNavigationLaunchTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
private val context: Context get() = harness.context
private val recipient: RecipientId get() = harness.others.first()
/**
* Share-target cold-launch regression test. Pre-fix, wrapNavigator() re-routed the
* early-staged Conversation through goTo(), whose async wallpaper-prefetch path emitted
* a SECOND internalDetailLocation with a fresh ConversationArgs — recreating the
* fragment and dropping share data.
*/
@Test
fun coldLaunch_shareIntent_createsFragmentExactlyOnceWithShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
try {
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
} catch (e: IllegalStateException) {
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
val state = runOnMainSync {
buildString {
appendLine("--- diagnostic dump ---")
appendLine("fragments observed: ${recorder.allCreated}")
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}")
}
}
throw IllegalStateException("${e.message}\n$state", e)
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
// Give the post-navigator wallpaper-prefetch path a chance to emit a (pre-fix)
// duplicate second nav before we count fragments.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.shareDataTimestamp == timestamp) {
"Expected shareDataTimestamp=$timestamp, got ${args.shareDataTimestamp}"
}
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == blob) {
"Expected draftMedia=$blob, got ${args.draftMedia}"
}
}
}
/**
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
* that hops the user from the conversation into the media-send screen
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
* corner — i.e. it knows who the share is targeted at.
*/
@Test
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
val media = realJpegMedia()
val intent = shareImageIntent(recipient = recipient, media = media)
launchSync(intent).use { launched ->
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
// await() already runs the predicate on the main thread; nesting another
// runOnMainSync here would throw "can not be called from the main application thread".
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
}
// Exactly one ConversationFragment should have been created — the share dispatch
// happens from inside it, then it stays put while the media editor sits on top.
check(launched.recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment for image share, got ${launched.recorder.createdArgs.size}"
}
}
}
/**
* Text-share cold-launch: the dispatch path through `ShareOrDraftData.SetText`. Asserts
* the navigation boundary — one ConversationFragment, no secondary activity pushed on
* top — *and* that the draft text actually shows up in the composer the user sees.
*/
@Test
fun coldLaunch_shareTextIntent_opensConversationWithDraftText() {
val draftText = "hello from share"
val intent = shareTextIntent(recipient = recipient, text = draftText)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
awaitComposerText(launched, draftText)
// Give a beat for any spurious second navigation to surface.
Thread.sleep(750)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(launched.nonMainActivities().isEmpty()) {
"Text share should not launch a secondary activity, got ${launched.nonMainActivities().map { it::class.simpleName }}"
}
}
}
@Test
fun coldLaunch_notificationIntent_opensConversation() {
val intent = notificationToConversationIntent(recipient)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
check(recorder.createdArgs.size == 1) {
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
}
val args = recorder.createdArgs.single()
check(args.recipientId == recipient) {
"Expected recipient=$recipient, got ${args.recipientId}"
}
check(args.threadId > 0) {
"Expected threadId > 0, got ${args.threadId}"
}
check(args.draftMedia == null) {
"Expected no draftMedia, got ${args.draftMedia}"
}
check(args.shareDataTimestamp == -1L) {
"Expected shareDataTimestamp=-1 for notification path, got ${args.shareDataTimestamp}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected currentListLocation=CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun coldLaunch_tabIntent_setsListLocation() {
val intent = tabIntent(MainNavigationListLocation.CALLS)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab launch, got ${recorder.createdArgs.size}"
}
}
}
/**
* Locks down present cold-launch behaviour for KEY_DETAIL_LOCATION: today it is only
* consumed by onNewIntent. If a future change starts handling it on cold launch, this
* test should fail and force a deliberate decision.
*/
@Test
fun coldLaunch_detailLocationIntent_isNoOpToday() {
val intent = detailLocationIntent(MainNavigationDetailLocation.Chats.ConversationSettings(recipient))
launchSync(intent).use { launched ->
val recorder = launched.recorder
Thread.sleep(1500)
check(recorder.createdArgs.isEmpty()) {
"KEY_DETAIL_LOCATION is currently only handled by onNewIntent. If a future change " +
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}"
}
}
}
@Test
fun coldLaunch_deepLinkIntent_reachesChatsList() {
val intent = deepLinkIntent(Uri.parse("https://signal.org/test-not-a-real-deeplink"))
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS for deep-link launch, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for deep-link launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun coldLaunch_noExtras_defaultsToChats() {
val intent = Intent(context, MainActivity::class.java)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
Thread.sleep(750)
check(vm.earlyNavigationDetailLocationRequested == null) {
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
}
}
}
@Test
fun warmStart_onNewIntent_conversationIntent_opensConversation() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
val recorder = launched.recorder
// Let the bare list settle so we know any further fragment adds came from onNewIntent.
Thread.sleep(1000)
val baseline = recorder.createdArgs.size
val warmIntent = notificationToConversationIntent(recipient)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(timeoutMs = 10_000, description = "ConversationFragment after onNewIntent") {
recorder.createdArgs.size > baseline
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val newArgs = recorder.createdArgs.drop(baseline)
check(newArgs.size == 1) { "Expected one new ConversationFragment, got ${newArgs.size}" }
check(newArgs.single().recipientId == recipient) {
"Expected recipient=$recipient, got ${newArgs.single().recipientId}"
}
}
}
/**
* Mid-conversation onNewIntent with `KEY_DETAIL_LOCATION = Empty` — the contract used
* by [ConversationSettingsFragment.goToConversationList] to drop back to the chat list
* on phones. No new ConversationFragment should be added.
*/
@Test
fun warmStart_onNewIntent_emptyDetailIntent_returnsToList() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val baseline = recorder.createdArgs.size
val warmIntent = detailLocationIntent(MainNavigationDetailLocation.Empty)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(description = "no new ConversationFragment after Empty detail intent") {
recorder.createdArgs.size == baseline
}
// The user-visible signal that we're "back on the list" is the chat list fragment
// being attached, not just the VM saying CHATS.
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
}
}
}
@Test
fun warmStart_onNewIntent_tabIntent_switchesList() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val warmIntent = tabIntent(MainNavigationListLocation.CALLS)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
awaitListFragment(launched, MainNavigationListLocation.CALLS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab switch, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midConversation_restoresState() {
launchSync(notificationToConversationIntent(recipient)).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initial = recorder.createdArgs.first()
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size >= 2
}
// Verify the user-visible title rebinds after recreate, not just the args.
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs[1]
check(recreated.recipientId == initial.recipientId) {
"Recipient changed across recreate: ${initial.recipientId} -> ${recreated.recipientId}"
}
check(recreated.threadId == initial.threadId) {
"Thread changed across recreate: ${initial.threadId} -> ${recreated.threadId}"
}
}
}
@Test
fun recreate_midTab_restoresTab() {
launchSync(tabIntent(MainNavigationListLocation.CALLS)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CALLS)
runOnMainSync { launched.activity.recreate() }
// Verify the user-visible tab content rebinds after recreate, not just the VM. The
// recorder removes destroyed fragments, so this only passes once the post-recreate
// CallLogFragment instance is attached.
awaitListFragment(launched, MainNavigationListLocation.CALLS)
// launched.activity returns the *latest* MainActivity (the holder updates in
// onActivityCreated), so this reads the post-recreate VM instance.
val location = runOnMainSync {
launched.activity.mainNavigationViewModel().mainNavigationState.value.currentListLocation
}
check(location == MainNavigationListLocation.CALLS) {
"Expected VM CALLS post-recreate, got $location"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment across tab recreate, got ${launched.recorder.createdArgs.size}"
}
}
}
@Test
fun recreate_midShareConversation_preservesShareData() {
val timestamp = System.currentTimeMillis()
val mimeType = "image/jpeg"
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
val intent = shareToConversationIntent(
recipient = recipient,
blob = blob,
mimeType = mimeType,
shareDataTimestamp = timestamp
)
launchSync(intent).use { launched ->
val recorder = launched.recorder
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
recorder.createdArgs.isNotEmpty()
}
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
awaitConversationTitle(launched, expectedName)
val initialCount = recorder.createdArgs.size
runOnMainSync { launched.activity.recreate() }
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
recorder.createdArgs.size > initialCount
}
awaitConversationTitle(launched, expectedName)
val recreated = recorder.createdArgs.last()
check(recreated.shareDataTimestamp == timestamp) {
"shareDataTimestamp not preserved across recreate: $timestamp -> ${recreated.shareDataTimestamp}"
}
check(recreated.draftMedia == blob) {
"draftMedia not preserved across recreate: $blob -> ${recreated.draftMedia}"
}
}
}
// region Helpers
/**
* Mirrors [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]. We
* deliberately drop the producer's `clearTop` flags (NEW_TASK | CLEAR_TOP | SINGLE_TOP)
* — they are launch-routing concerns that are incompatible with our lifecycle monitor.
*/
private fun shareToConversationIntent(
recipient: RecipientId,
blob: Uri,
mimeType: String,
draftText: String? = null,
shareDataTimestamp: Long = System.currentTimeMillis()
): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(blob)
.withDataType(mimeType)
.withMedia(emptyList())
.withDraftText(draftText)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(shareDataTimestamp)
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors the image-share path through [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]:
* a non-empty `media` list is what flips dispatch to `ShareOrDraftData.StartSendMedia`,
* which is what triggers the hop to the media-send screen.
*/
private fun shareImageIntent(recipient: RecipientId, media: Media): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withDataUri(media.uri)
.withDataType(media.contentType)
.withMedia(listOf(media))
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
/**
* Mirrors a text-only share. Empty media list + non-null draft text routes dispatch to
* `ShareOrDraftData.SetText`.
*/
private fun shareTextIntent(recipient: RecipientId, text: String): Intent {
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
val conversationIntent = builder
.withMedia(emptyList())
.withDraftText(text)
.withStickerLocator(null)
.asBorderless(false)
.withShareDataTimestamp(System.currentTimeMillis())
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun notificationToConversationIntent(recipient: RecipientId): Intent {
val conversationIntent = ConversationIntents.createBuilder(context, recipient, -1L)
.blockingGet()
.build()
return Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
}
}
private fun tabIntent(tab: MainNavigationListLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("STARTING_TAB", tab)
}
private fun detailLocationIntent(location: MainNavigationDetailLocation): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("DETAIL_LOCATION", location)
}
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
return BlobProvider.getInstance()
.forData(bytes)
.withMimeType(mimeType)
.createForSingleSessionInMemory()
}
/**
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
* is populated.
*/
private fun realJpegMedia(): Media {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
val bytes = ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
stream.toByteArray()
}
bitmap.recycle()
val uri = realBlob(bytes, "image/jpeg")
return Media(
uri = uri,
contentType = "image/jpeg",
date = 0L,
width = 1,
height = 1,
size = bytes.size.toLong(),
duration = 0L,
isBorderless = false,
isVideoGif = false,
bucketId = null,
caption = null,
transformProperties = null,
fileName = null
)
}
/**
* Mirrors [org.thoughtcrime.securesms.deeplinks.DeepLinkEntryActivity]: bare clearTop
* plus a [Uri] in the data field.
*/
private fun deepLinkIntent(data: Uri): Intent {
return Intent(context, MainActivity::class.java).setData(data)
}
/**
* Synchronously launch [MainActivity] and return the running instance plus a fragment
* recorder wired up *before* the activity is created.
*
* We bypass [androidx.test.core.app.ActivityScenario] and
* [android.app.Instrumentation.startActivitySync] because both fail for our case:
* ActivityScenario's lifecycle tracker misses CREATED/STARTED/RESUMED for activities
* launched with a custom-action intent, and `startActivitySync` waits for main-thread
* idle which never arrives while MainActivity's composition + ConversationFragment
* setup keeps the looper busy.
*/
private fun launchSync(intent: Intent): LaunchedActivity {
val recorder = ConversationFragmentRecorder()
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val activityHolder = arrayOfNulls<MainActivity>(1)
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities += activity
if (activity is MainActivity) {
activityHolder[0] = activity
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(recorder, true)
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) resumed.countDown()
}
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(callbacks)
// Application.startActivity from a non-Activity context requires FLAG_ACTIVITY_NEW_TASK.
val launchIntent = Intent(intent).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
try {
app.startActivity(launchIntent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(callbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(callbacks)
error("MainActivity did not reach RESUMED within 15s")
}
return LaunchedActivity(activityHolder, recorder, app, callbacks, allActivities)
}
private fun <T> runOnMainSync(block: () -> T): T {
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync did not produce a result"))
InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = runCatching(block)
}
return result.getOrThrow()
}
private fun await(
timeoutMs: Long = 5_000,
pollMs: Long = 50,
description: String = "condition",
predicate: () -> Boolean
) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (runOnMainSync(predicate)) return
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun MainActivity.mainNavigationViewModel(): MainNavigationViewModel {
return ViewModelProvider(this as FragmentActivity, MainNavigationViewModel.Factory())[MainNavigationViewModel::class.java]
}
/**
* Wait until the latest [ConversationFragment]'s composer EditText shows [expected].
* setDraftText is invoked off the InputReadyState/ShareOrDraftData reactive chain, so the
* text won't be present at fragment-create time — we have to poll the rendered view.
*/
private fun awaitComposerText(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "composer shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
view.findViewById<TextView>(R.id.embedded_text_editor)?.text?.toString() == expected
}
}
/**
* Wait until the latest [ConversationFragment]'s toolbar shows [expected]. Scoped through
* R.id.conversation_title_view to avoid colliding with other R.id.title uses.
*/
private fun awaitConversationTitle(launched: LaunchedActivity, expected: String) {
await(timeoutMs = 15_000, description = "conversation title shows \"$expected\"") {
val frag = launched.recorder.latestActive() ?: return@await false
val view = frag.view ?: return@await false
val titleHost = view.findViewById<View>(R.id.conversation_title_view) ?: return@await false
titleHost.findViewById<TextView>(R.id.title)?.text?.toString() == expected
}
}
/**
* MainActivity hosts each tab as a different [Fragment] via Compose's `AndroidFragment`
* (see MainActivity.kt:662-698). The user sees the content of whichever one is currently
* attached, so a tab assertion that reads the FragmentManager is a real user-visible
* signal — strictly stronger than reading the VM's `currentListLocation`.
*/
private fun listFragmentClass(location: MainNavigationListLocation): Class<out Fragment> = when (location) {
MainNavigationListLocation.CHATS -> ConversationListFragment::class.java
MainNavigationListLocation.ARCHIVE -> ConversationListArchiveFragment::class.java
MainNavigationListLocation.CALLS -> CallLogFragment::class.java
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
}
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
val expected = listFragmentClass(location)
try {
await(timeoutMs = 10_000, description = "${expected.simpleName} attached for $location") {
launched.recorder.isAttached(expected)
}
} catch (e: IllegalStateException) {
throw IllegalStateException("${e.message}; currently attached: ${launched.recorder.attachedNames()}", e)
}
}
// endregion
// region Types
/**
* Records every [ConversationFragment] added under an activity's fragment manager,
* capturing each fragment's arguments at create-time.
*/
private class ConversationFragmentRecorder : FragmentManager.FragmentLifecycleCallbacks() {
val createdArgs: MutableList<ConversationArgs> = mutableListOf()
val allCreated: MutableList<String> = mutableListOf()
private val active: MutableList<ConversationFragment> = mutableListOf()
private val attached: MutableList<Fragment> = mutableListOf()
var destroyedCount: Int = 0
private set
/** Most-recently-added still-attached ConversationFragment, or null. Main-thread read. */
fun latestActive(): ConversationFragment? = active.lastOrNull()
/**
* Exact class match (not [Class.isInstance]) — `ConversationListArchiveFragment`
* extends `ConversationListFragment`, so an `isInstance` check for CHATS would falsely
* pass when the archive list is attached.
*/
fun isAttached(clazz: Class<out Fragment>): Boolean = attached.any { it::class.java == clazz }
fun attachedNames(): List<String> = attached.map { it::class.simpleName ?: it::class.java.name }
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: android.os.Bundle?) {
allCreated += f::class.simpleName ?: f::class.java.name
attached += f
if (f is ConversationFragment) {
createdArgs += ConversationIntents.readArgsFromBundle(f.requireArguments())
active += f
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
attached.remove(f)
if (f is ConversationFragment) {
active.remove(f)
destroyedCount++
}
}
}
private class LaunchedActivity(
private val activityHolder: Array<MainActivity?>,
val recorder: ConversationFragmentRecorder,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
/**
* Always returns the *latest* MainActivity instance so reads follow `recreate()`.
*/
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
/**
* Poll until an activity of [clazz] has been created, then return it. Used to assert
* the share-image flow's hop into MediaSelectionActivity.
*/
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val match = synchronized(allActivities) {
allActivities.firstOrNull { clazz.isInstance(it) }
}
if (match != null) return clazz.cast(match)!!
Thread.sleep(50)
}
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
error("Timed out after ${timeoutMs}ms waiting for ${clazz.simpleName}; saw $seen")
}
fun nonMainActivities(): List<Activity> = synchronized(allActivities) {
allActivities.filter { it !is MainActivity }.toList()
}
override fun close() {
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
// opened by share processing) can keep it busy indefinitely. Finish every tracked
// activity so subsequent tests start from a clean slate.
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
// endregion
}
@@ -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.api.NetworkResult
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()
}
@@ -17,7 +17,6 @@ import org.whispersystems.signalservice.internal.push.PreKeyResponse
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
import org.whispersystems.signalservice.internal.push.SenderCertificate
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.security.SecureRandom
@@ -27,8 +26,6 @@ import java.security.SecureRandom
*/
object MockProvider {
val senderCertificate = SenderCertificate().apply { certificate = ByteArray(0) }
val lockedFailure = PushServiceSocket.RegistrationLockFailure().apply {
svr1Credentials = AuthCredentials.create("username", "password")
svr2Credentials = AuthCredentials.create("username", "password")
@@ -75,8 +72,8 @@ object MockProvider {
val device = PreKeyResponseItem().apply {
this.deviceId = deviceId
registrationId = KeyHelper.generateRegistrationId(false)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
}
return PreKeyResponse().apply {
@@ -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)
}
}
}
}
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
@@ -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 }
}
}
@@ -74,7 +74,7 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress(), SignalProtocolAddress(serviceAddress.identifier, 1)))
sessionBuilder.process(getAlicePreKeyBundle())
}
@@ -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>
+3 -4
View File
@@ -2,8 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
<!-- ======================================= -->
<!-- Features -->
<!-- ======================================= -->
@@ -40,6 +38,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
@@ -482,7 +481,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name="org.signal.mediasend.MediaSendActivity"
android:name=".mediasend.v3.MediaSendV3Activity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"
android:launchMode="singleTop"
@@ -1405,7 +1404,7 @@
<service
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
android:foregroundServiceType="dataSync|microphone|camera|phoneCall|mediaProjection" />
<service
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,10 +62,12 @@ 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;
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob;
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
@@ -73,20 +79,23 @@ 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;
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob;
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,16 +113,16 @@ import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
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;
@@ -221,11 +230,12 @@ 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)
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
@@ -317,7 +327,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
* This is so we can capture ANR's that happen on boot before the foreground event.
*/
private void startAnrDetector() {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), RemoteConfig::internalUser, (dumps) -> {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), () -> RemoteConfig.internalUser() && SignalStore.internal().getAnrDetectionCrashes(), (dumps) -> {
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
return Unit.INSTANCE;
});
@@ -400,6 +410,25 @@ public class ApplicationContext extends Application implements AppForegroundObse
AppDependencies.init(this, new ApplicationDependencyProvider(this));
}
AppForegroundObserver.begin();
if (Environment.USE_NEW_REGISTRATION) {
initializeRegistrationDependencies();
}
}
private void initializeRegistrationDependencies() {
org.signal.registration.RegistrationDependencies.Companion.provide(
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
Environment.IS_LINK_AND_SYNC_AVAILABLE,
null,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
return Unit.INSTANCE;
}
)
);
}
private void initializeFirstEverAppLaunch() {
@@ -421,14 +450,38 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void initializeFcmCheck() {
if (SignalStore.account().isRegistered()) {
if (!SignalStore.account().isRegistered()) {
return;
}
PlayServicesUtil.PlayServicesStatus playServicesStatus = PlayServicesUtil.getPlayServicesStatus(this);
if (playServicesStatus == PlayServicesUtil.PlayServicesStatus.SUCCESS && !SignalStore.account().isFcmEnabled()) {
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. 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 {
Log.d(TAG, "Play Services status: " + playServicesStatus + ", fcmEnabled: false. Skipping FCM check.");
}
}
@@ -459,6 +512,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
if (RemoteConfig.internalUser()) {
Tracer.getInstance().setMaxBufferSize(35_000);
}
SQLiteDatabase.setSlowWriteLoggingEnabled(RemoteConfig.slowDatabaseNotifications());
}
private void initializePeriodicTasks() {
@@ -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,7 +36,6 @@ 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;
@@ -47,36 +44,41 @@ import androidx.transition.TransitionManager;
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;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks;
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.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository;
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.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,6 +94,7 @@ 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;
@@ -117,21 +120,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private ContactSearchView contactSearchView;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
private ContactSearchViewModel contactSearchViewModel;
@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;
@@ -158,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());
}
@@ -211,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;
}
@@ -238,7 +226,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
handleContactPermissionGranted();
} else {
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
}
}
@@ -246,28 +234,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recyclerView.setLayoutManager(layoutManager);
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
recyclerView.setAlpha(1f);
}
});
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
@@ -283,12 +254,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
if (fragmentArgs.getRecyclerPadBottom() != -1) {
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
}
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
@@ -302,47 +267,27 @@ public final class ContactSelectionListFragment extends LoggingFragment {
currentSelection = getCurrentSelection();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
Set<ContactSearchKey> fixedContacts = currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(Collectors.toSet());
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (hideLetterHeaders()) {
return;
}
int firstPosition = layoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = recyclerView.getChildAt(0);
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
contactSearchMediator = new ContactSearchMediator(
contactSearchViewModel = new ViewModelProvider(
this,
currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
selectionLimit,
isMulti,
new ContactSearchViewModel.Factory(
selectionLimit,
isMulti,
new ContactSearchRepository(),
false,
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts
)
).get(ContactSearchViewModel.class);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
new ContactSearchAdapter.DisplayOptions(
isMulti,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
@@ -350,52 +295,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
false
),
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
new ContactSearchCallbacks.Simple() {
@Override
public void onAdapterListCommitted(int size) {
onLoadFinished(size);
}
},
false,
(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter(
context,
fixedContacts,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchMediator.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
ContactSelectionListModels.composeEntries(
new ContactSelectionListModels.Callback() {
@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) {
@@ -408,36 +320,64 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
}
),
new ContactSelectionListAdapter.ArbitraryRepository()
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;
@@ -460,30 +400,30 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public @NonNull List<SelectedContact> getSelectedContacts() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return Collections.emptyList();
}
return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(java.util.stream.Collectors.toList());
return contactSearchViewModel.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(Collectors.toList());
}
public int getSelectedContactsCount() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return 0;
}
return contactSearchMediator.getSelectedContacts().size();
return contactSearchViewModel.getSelectedContacts().size();
}
public int getTotalMemberCount() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return 0;
}
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
return getSelectedContactsCount() + contactSearchViewModel.getFixedContactsSize();
}
private Set<RecipientId> getCurrentSelection() {
@@ -499,36 +439,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
.request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS)
.ifNecessary()
.onAllGranted(() -> {
recyclerView.setAlpha(0.5f);
contactSearchView.setAlpha(0.5f);
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
} else {
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
if (onRefreshListener != null) {
swipeRefresh.setRefreshing(true);
onRefreshListener.onRefresh();
}
}
})
.onAnyDenied(() -> contactSearchMediator.refresh())
.onAnyDenied(() -> contactSearchViewModel.refresh())
.withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts), null, R.string.ContactSelectionListFragment_allow_access_contacts, R.string.ContactSelectionListFragment_to_find_people, getParentFragmentManager())
.execute();
}
private void initializeCursor() {
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(contactSearchMediator.getAdapter());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
if (scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
}
});
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
@@ -546,7 +473,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.resetPositionOnCommit = true;
this.cursorFilter = filter;
contactSearchMediator.onFilterChanged(filter);
contactSearchViewModel.setQuery(filter);
}
public void resetQueryFilter() {
@@ -557,7 +484,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void onDataRefreshed() {
this.resetPositionOnCommit = true;
swipeRefresh.setRefreshing(false);
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
}
public boolean hasQueryFilter() {
@@ -573,35 +500,25 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public void reset() {
contactSearchMediator.clearSelection();
contactSearchMediator.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
contactSearchViewModel.setFastScrollEnabled(false);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit) {
resetPositionOnCommit = false;
recyclerView.scrollToPosition(0);
contactSearchViewModel.requestScrollPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
if (useFastScroller) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(recyclerView);
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);
}
}
@@ -659,10 +576,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
Set<SelectedContact> toMarkSelected = contacts.stream()
.filter(r -> !contactSearchMediator.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.filter(r -> !contactSearchViewModel.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.map(SelectedContact::forRecipientId)
.collect(java.util.stream.Collectors.toSet());
.collect(Collectors.toSet());
if (toMarkSelected.isEmpty()) {
return;
@@ -687,7 +604,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectedContact.hasChatType() && !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
if (allowed) {
@@ -704,7 +621,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (!isMulti || !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
@@ -772,7 +689,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
} else {
return false;
}
@@ -792,7 +709,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public void markContactSelected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactSearchViewModel.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
@@ -802,7 +719,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactSearchViewModel.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) {
@@ -864,8 +781,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
AutoTransition transition = new AutoTransition();
transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS);
transition.excludeChildren(recyclerView, true);
transition.excludeTarget(recyclerView, true);
transition.excludeChildren(contactSearchView, true);
transition.excludeTarget(contactSearchView, true);
TransitionManager.beginDelayedTransition(constraintLayout, transition);
ConstraintSet constraintSet = new ConstraintSet();
@@ -914,19 +831,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) {
@@ -948,10 +865,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
@@ -998,13 +917,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) {
@@ -1094,15 +1013,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
}
}
@@ -44,7 +44,6 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
@@ -61,6 +60,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
@@ -73,7 +73,6 @@ import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import androidx.window.core.layout.WindowSizeClass
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
@@ -86,8 +85,9 @@ import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.isSplitPane
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
@@ -117,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
@@ -163,6 +164,7 @@ import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -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 :
@@ -271,7 +273,7 @@ class MainActivity :
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
@@ -298,7 +300,7 @@ class MainActivity :
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
mediaSendLauncher = mediaSendLauncher()
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
@@ -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(
@@ -428,15 +449,15 @@ class MainActivity :
)
}
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData(mainToolbarState.mode)
MainContainer {
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
val wrappedNavigator = rememberNavigator(isSplitPane, contentLayoutData, maxWidth)
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
val navigationType = NavigationType.rememberNavigationType()
val anchors = remember(contentLayoutData, mainToolbarState) {
val anchors = remember(contentLayoutData, mainToolbarState, listPaneWidth, navigationType) {
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
val detailOffset = when {
@@ -464,7 +485,7 @@ class MainActivity :
anchors.indexOf(paneExpansionState.currentAnchor)
}
LaunchedEffect(windowSizeClass) {
LaunchedEffect(anchors) {
val index = when {
paneAnchorIndex < 0 -> 1
paneAnchorIndex > anchors.lastIndex -> anchors.lastIndex
@@ -477,7 +498,7 @@ class MainActivity :
}
}
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
@@ -520,15 +541,14 @@ class MainActivity :
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> {
if (location is MainNavigationDetailLocation.Chats.Conversation) {
chatNavGraphState.writeGraphicsLayerToBitmap()
}
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -623,7 +643,7 @@ class MainActivity :
onDestinationSelected = mainNavigationCallback
)
if (!windowSizeClass.isSplitPane()) {
if (!LocalResources.current.rememberIsSplitPane()) {
Spacer(Modifier.navigationBarsPadding())
}
}
@@ -639,7 +659,7 @@ class MainActivity :
}
},
secondaryContent = {
val listContainerColor = if (windowSizeClass.isSplitPane()) {
val listContainerColor = if (isSplitPane) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
@@ -780,12 +800,12 @@ class MainActivity :
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun rememberNavigator(
windowSizeClass: WindowSizeClass,
isSplitPane: Boolean,
contentLayoutData: MainContentLayoutData,
maxWidth: Dp
): AppScaffoldNavigator<Any> {
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
isSplitPane = windowSizeClass.isSplitPane(),
isSplitPane = isSplitPane,
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
)
@@ -799,18 +819,18 @@ class MainActivity :
@Composable
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
CompositionLocalProvider(LocalSnackbarStateConsumerRegistry provides mainNavigationViewModel.snackbarRegistry) {
SignalTheme {
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
val backgroundColor = if (!isSplitPane) {
MaterialTheme.colorScheme.surface
} else {
SignalTheme.colors.colorSurface1
}
val modifier = when {
windowSizeClass.isSplitPane() -> {
isSplitPane -> {
Modifier
.systemBarsPadding()
.displayCutoutPadding()
@@ -847,7 +867,7 @@ class MainActivity :
val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainNavigationDetailLocation::class.java)
if (detailLocation != null) {
mainNavigationViewModel.goTo(detailLocation)
goTo(detailLocation)
return
}
@@ -1033,7 +1053,7 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
setIntent(intent)
}
@@ -1124,7 +1144,7 @@ class MainActivity :
if (isForQuickRestore) {
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
} else if (SignalStore.internal.useNewMediaActivity) {
mediaActivityLauncher.launch(
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isStory = destination == MainNavigationListLocation.STORIES
@@ -50,7 +50,7 @@ public class MainNavigator {
.withStartingPosition(startingPosition)
.asIncognito(incognito)
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(args)));
lifecycleDisposable.add(disposable);
}
@@ -16,7 +16,6 @@
*/
package org.thoughtcrime.securesms;
import android.animation.Animator;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
@@ -49,7 +48,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
import org.thoughtcrime.securesms.crypto.MasterSecret;
@@ -389,13 +387,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
Log.i(TAG, "onAuthenticationSucceeded");
lockScreenButton.setOnClickListener(null);
unlockView.addAnimatorListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
handleAuthenticated();
}
});
unlockView.playAnimation();
handleAuthenticated();
}
@Override
@@ -30,9 +30,10 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
startActivity(intent);
finish();
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
startActivity(intent);
} else {
startActivity(intent);
finish();
}
}
}
@@ -173,7 +178,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_ENTER_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {
} else if (userMustCreateSignalPin() && getClass() != CreateSvrPinActivity.class) {
return STATE_CREATE_SIGNAL_PIN;
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
return STATE_TRANSFER_ONGOING;
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this);
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
}
private Intent getEnterSignalPinIntent() {
@@ -19,7 +19,7 @@ package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Bundle;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.SelectedContact;
@@ -58,7 +58,7 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
protected final void onFinishedSelection() {
Intent resultIntent = getIntent();
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId()).toList();
List<RecipientId> recipients = selectedContacts.stream().map(sc -> sc.getOrCreateRecipientId()).collect(Collectors.toList());
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
@@ -10,12 +10,15 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.TaskStackBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
public class ShortcutLauncherActivity extends AppCompatActivity {
private static final String TAG = Log.tag(ShortcutLauncherActivity.class);
private static final String KEY_RECIPIENT = "recipient_id";
public static Intent createIntent(@NonNull Context context, @NonNull RecipientId recipientId) {
@@ -30,9 +33,18 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
String rawId = getIntent().getStringExtra(KEY_RECIPIENT);
RecipientId recipientId = null;
if (rawId == null) {
if (rawId != null) {
try {
recipientId = RecipientId.from(rawId);
} catch (Throwable t) {
Log.w(TAG, "Failed to parse recipientId from intent.", t);
}
}
if (recipientId == null) {
Toast.makeText(this, R.string.ShortcutLauncherActivity_invalid_shortcut, Toast.LENGTH_SHORT).show();
// TODO [greyson] Navigation
startActivity(MainActivity.clearTop(this));
@@ -40,7 +52,7 @@ public class ShortcutLauncherActivity extends AppCompatActivity {
return;
}
Recipient recipient = Recipient.live(RecipientId.from(rawId)).get();
Recipient recipient = Recipient.live(recipientId).get();
// TODO [greyson] Navigation
TaskStackBuilder backStack = TaskStackBuilder.create(this)
.addNextIntent(MainActivity.clearTop(this));
@@ -5,11 +5,13 @@
package org.thoughtcrime.securesms.apkupdate
import android.app.DownloadManager
import android.app.PendingIntent
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
@@ -17,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
@@ -53,6 +54,13 @@ object ApkUpdateInstaller {
return
}
if (!isDownloadSuccessful(context, downloadId)) {
Log.w(TAG, "DownloadId matches, but the download was not successful. The download may have failed due to a network issue. Clearing state and re-checking for updates.")
SignalStore.apkUpdate.clearDownloadAttributes()
AppDependencies.jobManager.add(ApkUpdateJob())
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
@@ -134,6 +142,35 @@ object ApkUpdateInstaller {
}
}
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = context.getDownloadManager().query(query)
return cursor.use { cursor ->
if (cursor.moveToFirst()) {
val status = cursor
.getColumnIndex(DownloadManager.COLUMN_STATUS)
.takeUnless { it == -1 }
?.let { cursor.getInt(it) } ?: DownloadManager.STATUS_FAILED
if (status == DownloadManager.STATUS_SUCCESSFUL) {
return@use true
}
val reason = cursor
.getColumnIndex(DownloadManager.COLUMN_REASON)
.takeUnless { it == -1 }
?.let { cursor.getInt(it) }
Log.w(TAG, "Download not successful. Status: $status, Reason: $reason")
false
} else {
Log.w(TAG, "Download ID $downloadId not found in DownloadManager.")
false
}
}
}
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
@@ -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
@@ -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 {
@@ -145,6 +145,11 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
if (cdn == Cdn.S3) {
return Optional.empty()
}
return Optional.of(
PointerAttachment(
quote = true,
@@ -153,7 +158,7 @@ class PointerAttachment : Attachment {
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
fileName = quotedAttachment.fileName,
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
cdn = cdn,
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
key = thumbnail?.asPointer()?.key?.let { Base64.encodeWithPadding(it) },
iv = null,
@@ -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)
@@ -16,8 +16,8 @@ import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
import java.util.concurrent.TimeUnit
@@ -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
@@ -11,8 +11,6 @@ import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
@@ -71,6 +69,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import okio.ByteString;
@@ -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
@@ -175,6 +175,10 @@ object ExportSkips {
return log(sentTimestamp, "Invalid e164 in sessions switchover event. Exporting an empty event.")
}
fun donationRequestNotInReleaseNotesChat(sentTimestamp: Long): String {
return log(sentTimestamp, "Donation request not in Release Notes chat.")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
@@ -30,8 +31,10 @@ 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 org.thoughtcrime.securesms.util.safeUnregisterReceiver
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@@ -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()
@@ -203,6 +222,7 @@ object ArchiveRestoreProgress {
state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED
else -> ArchiveRestoreProgressState.RestoreStatus.NONE
}
else -> {
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
@@ -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
@@ -66,6 +67,12 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.ApplicationErrorAction
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
@@ -141,12 +148,8 @@ 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.ApplicationErrorAction
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
@@ -160,8 +163,6 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
@@ -424,6 +425,12 @@ object BackupRepository {
}
fun markOutOfRemoteStorageSpaceError() {
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
return
}
SignalStore.backup.markNotEnoughRemoteStorageSpace()
val context = AppDependencies.application
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
@@ -436,8 +443,6 @@ object BackupRepository {
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification)
SignalStore.backup.markNotEnoughRemoteStorageSpace()
}
fun clearOutOfRemoteStorageSpaceError() {
@@ -1460,6 +1465,7 @@ object BackupRepository {
}
SignalDatabase.remappedRecords.clearCache()
SignalDatabase.remappedRecords.trimStaleMappings()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()
@@ -1624,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 ->
@@ -1686,10 +1679,10 @@ object BackupRepository {
*
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
*/
fun getAttachmentUploadForm(): NetworkResult<AttachmentUploadForm> {
fun getAttachmentUploadForm(uploadLength: Long): NetworkResult<AttachmentUploadForm> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess)
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess, uploadLength)
}
}
@@ -2090,7 +2083,7 @@ object BackupRepository {
}
/**
* See [org.whispersystems.signalservice.api.archive.ArchiveApi.getSvrBAuthorization].
* See [org.signal.network.api.ArchiveApi.getSvrBAuthorization].
*/
fun getSvrBAuth(): NetworkResult<AuthCredentials> {
return initBackupAndFetchAuth()
@@ -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
@@ -241,6 +241,10 @@ class ChatItemArchiveExporter(
}
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
if (exportState.threadIdToRecipientId[builder.chatId] != exportState.releaseNoteRecipientId) {
Log.w(TAG, ExportSkips.donationRequestNotInReleaseNotesChat(builder.dateSent))
continue
}
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
transformTimer.emit("simple-update")
}
@@ -318,7 +322,7 @@ class ChatItemArchiveExporter(
}
MessageTypes.isSessionSwitchoverType(record.type) -> {
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent) ?: continue
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent)?.takeIf { builder.authorIsAciContact(exportState) } ?: continue
transformTimer.emit("sse")
}
@@ -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()

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