Compare commits

...

699 Commits

Author SHA1 Message Date
Cody Henthorne 39598a8da0 Bump version to 8.20.2 2026-07-16 16:33:22 -04:00
Cody Henthorne 5949d96cfd Update translations and other static files. 2026-07-16 16:29:26 -04:00
Alex Hart c9ebc1c12e Replace lottie with static badge image and update sheet sizing. 2026-07-16 17:15:28 -03:00
Cody Henthorne 99ed40580c Add missing field for 1:1 sync messages. 2026-07-16 15:53:14 -04:00
Cody Henthorne c5b48be263 Bump version to 8.20.1 2026-07-16 14:10:13 -04:00
Cody Henthorne 1742b2ec50 Update baseline profile. 2026-07-16 13:55:55 -04:00
Cody Henthorne 49ef205821 Update translations and other static files. 2026-07-16 13:47:16 -04:00
Cody Henthorne 6f81a08669 Prevent jobs from running during a backup restore. 2026-07-16 13:38:35 -04:00
Cody Henthorne 97a8795100 Keep device alive during backup restore flows and show progress for link and sync import. 2026-07-16 13:38:35 -04:00
Greyson Parrelli b8508af636 Update title sizes in two-pane layouts in regV5. 2026-07-16 13:38:35 -04:00
Greyson Parrelli c0f861d0b8 Fix rate limit displays for verification codes in regV5. 2026-07-16 13:38:35 -04:00
Alex Hart 59d61184f5 Adjust span count and padding for tablet avatar picker. 2026-07-16 13:38:35 -04:00
Cody Henthorne 622208831f Enable link and sync for external users. 2026-07-16 13:38:35 -04:00
Greyson Parrelli 67b50b499e Improve logging in regV5. 2026-07-16 13:38:35 -04:00
Cody Henthorne 4cafe16e25 Block additional entry points from being accessed on a linked device. 2026-07-16 13:38:35 -04:00
Greyson Parrelli 2f70d780d0 Use better progress for local backup v2 in regV5. 2026-07-16 13:38:35 -04:00
Greyson Parrelli fd83ad9625 Increase max allowable image size. 2026-07-15 23:47:40 -04:00
Greyson Parrelli b3857e4b35 Handle missing sessions when submitting captcha in regV5. 2026-07-15 23:44:58 -04:00
Cody Henthorne eff0e973aa Bump version to 8.20.0 2026-07-15 17:40:28 -04:00
Cody Henthorne 34f336c2a2 Update baseline profile. 2026-07-15 17:13:28 -04:00
Cody Henthorne b0f71c8613 Update translations and other static files. 2026-07-15 17:03:02 -04:00
Cody Henthorne 95019407c2 Hide optimize media on linked devices. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 0101d41cf9 Enable regV5 for external users. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 75c18806d5 Ensure we always use the user-entered AEP in regV5. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 7a07a96fba Fix local backup v2 restore pre-registration in regV5. 2026-07-15 16:43:50 -04:00
Cody Henthorne 4f6c1c4647 Show backups information on linked devices and allow on-device backups. 2026-07-15 16:43:50 -04:00
Greyson Parrelli df4afe4e92 Fix time display for local backup v2 in regV5. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 6a18bcc915 Use better country code fallback in regV5. 2026-07-15 16:43:50 -04:00
Michelle Tang 06e7b79e3d Enable disappearing calls. 2026-07-15 16:43:50 -04:00
Greyson Parrelli 31f0945db4 Add tests for AppRegistrationStorageController. 2026-07-15 16:43:50 -04:00
Greyson Parrelli edc629e7fa Remove OneTimeEvent pattern from regV5. 2026-07-15 16:43:49 -04:00
Alex Hart bdfe06ed74 Upgrade compose bom to 2026.06.01 2026-07-15 16:43:49 -04:00
Cody Henthorne 1d11e29e94 Add linked device account screen. 2026-07-15 16:43:49 -04:00
andrew-signal f70ae10067 Bump libsignal to v0.97.3 2026-07-15 16:43:49 -04:00
Cody Henthorne 7ca57e5531 Fix flakey attachmentBackfill instrumentation test. 2026-07-15 16:43:49 -04:00
Greyson Parrelli adad1425d8 Navigate reglock mismatch to account locked in regV5. 2026-07-15 16:43:49 -04:00
Cody Henthorne 693b208fe2 Fix chat session refreshed on secondary after link+sync. 2026-07-15 16:43:49 -04:00
Michelle Tang e975084b8e Use new transcoding config. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 3bc693961d Fix QR code color in dark theme in regV5. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 2dde0b43b8 Take a persistent URI permission for local backups in regV5. 2026-07-15 16:43:49 -04:00
Alex Hart 88366cd416 Add donation checkout entry-point tests. 2026-07-15 16:43:49 -04:00
Greyson Parrelli 4ee2b71b13 Fix reglock interaction during backup restore in regV5. 2026-07-15 16:43:49 -04:00
Alex Hart 86aae39141 Fix nav for expiring timer. 2026-07-15 16:43:48 -04:00
Greyson Parrelli e71ec7c6d9 Refactor regV5 account storage persistence. 2026-07-15 16:43:48 -04:00
Michelle Tang 20d24d177d Refresh attributes on KT failure. 2026-07-15 16:43:48 -04:00
Alex Hart cfc1ed8d5c Check isResolving flag before displaying 'Deleted account'.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-07-15 16:43:48 -04:00
Alex Hart 25f7c31e75 Fix backup subscription check test. 2026-07-15 16:43:48 -04:00
Greyson Parrelli 929a391d50 Improve local restore progress in regV5. 2026-07-15 16:43:48 -04:00
dependabot[bot] 76f2543308 ci: bump the actions group with 2 updates.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 16:43:48 -04:00
Cody Henthorne 5eebaa3528 Update support article links in linked device reg flow. 2026-07-15 16:43:48 -04:00
Greyson Parrelli 673324f46a Fix text theming issue in contact search. 2026-07-15 16:43:48 -04:00
Greyson Parrelli ea9cd4251e Fix pin restore from remote backup in regV5. 2026-07-15 16:43:48 -04:00
Cody Henthorne ac7d54331a Update assets for linked device registration flows. 2026-07-15 16:43:48 -04:00
Alex Hart 5e3a34e1bc Add donation checkout spec test suite.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-07-15 16:43:47 -04:00
Greyson Parrelli fe6a74eb56 Add end-to-end regV5 tests for wrong backup passphrases. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 6a6b7a1924 Improve quick restore in regV5. 2026-07-15 16:43:47 -04:00
Greyson Parrelli f50e970b5d Add more end-to-end regV5 tests. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 32961106e1 Add an example end-to-end registration junit test. 2026-07-15 16:43:47 -04:00
andrew-signal 8d3728b32a Bump libsignal to v0.97.1 2026-07-15 16:43:47 -04:00
emir-signal 2f3b25c787 Update to RingRTC v2.69.7 2026-07-15 16:43:47 -04:00
BarbossHack 5cc7266a9e Refactor the XML diff in reproducible script
Closes signalapp/Signal-Android#14879
2026-07-15 16:43:47 -04:00
Greyson Parrelli 90cba056f1 Remove emoji from plaintext export names. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 07ca23ceae Evaluate original envelope source when rejecting sealed-sender PNI messages. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 0d0b9d345c Strip user-ID prefix from content authority in isValidExternalUri. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 7f63af659d Preserve storageCapable across restore progress emissions in local backup V1 restore. 2026-07-15 16:43:47 -04:00
Greyson Parrelli 66390b4fe4 Catch malformed percent escapes when parsing call link urls. 2026-07-15 16:43:47 -04:00
Greyson Parrelli b60f53f4df Reject dot-only chat names to prevent zip-slip in plaintext export. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 8bc31386a5 Build eager prefetch sessions under the SignalSessionLock. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 8f4b4aa160 Improve check for self. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 12af41174a Cap mention processing in story reply and edit message paths. 2026-07-15 16:43:46 -04:00
Greyson Parrelli e7c5af7ea5 Handle device-transfer authentication failures per connection instead of terminating the server. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 51b1dbd470 Bound backup frame read retries and cap frame length to prevent restore crash. 2026-07-15 16:43:46 -04:00
Greyson Parrelli dd38f72f60 Validate content URI authority in system contacts entrypoint. 2026-07-15 16:43:46 -04:00
Greyson Parrelli db8a705704 Ensure backups are re-enabled post-reg in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli ba43403009 Restore the AEP in v1 local backups in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 417b9eb50a Report progress for V1 local restore in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 76c9b4a7dc Do not reprompt for restore if you restored pre-reg in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli d1b916d61c Fix local backup v1 restores post-registration in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 908bb8dbe6 Fix race condition with event stream in regV5. 2026-07-15 16:43:46 -04:00
Greyson Parrelli 31216592de Ensure that all regV5 viewmodels follow the right patterns. 2026-07-15 16:43:45 -04:00
Greyson Parrelli bcc9f17e41 Use regV5 for partial restore state. 2026-07-15 16:43:45 -04:00
Greyson Parrelli d9dd15eff3 Use idiomatic nav3 transition patterns. 2026-07-15 16:43:45 -04:00
Alex Hart 1a12eb992d Fix crash when trying to dismiss a detached dialog window. 2026-07-15 16:43:45 -04:00
Greyson Parrelli 7c1e010482 Improve phone number pasting in regV5. 2026-07-15 16:43:45 -04:00
Greyson Parrelli 8a5826555c Properly restore keys from local backups in regV5. 2026-07-10 16:56:33 -04:00
Greyson Parrelli 8ffbfb4000 Fix local backup restore v2 in regV5. 2026-07-10 16:56:32 -04:00
Greyson Parrelli 16fe25a38a Fix some remote restore bugs in regV5. 2026-07-10 16:56:32 -04:00
Alex Hart c47a74ba09 Bump version to 8.19.2 2026-07-10 16:53:22 -03:00
Alex Hart 24287a15e2 Update translations and other static files. 2026-07-10 16:52:50 -03:00
Michelle Tang 77bef0b101 Disable disappearing calls. 2026-07-10 15:21:19 -04:00
Greyson Parrelli e2ef8c2805 Only run screenshot tests on CI for now. 2026-07-09 13:44:09 -04:00
Greyson Parrelli a52799e069 Update screenshot test with new translation. 2026-07-09 13:44:09 -04:00
Alex Hart 2cabdd0978 Bump version to 8.19.1 2026-07-08 16:18:02 -03:00
Alex Hart 02e134c16f Update translations and other static files. 2026-07-08 16:17:20 -03:00
Alex Hart fe87ae203a Revert "Update translations and other static files."
This reverts commit 1bad283af2.
2026-07-08 16:01:16 -03:00
Alex Hart 272754384d Bump version to 8.19.0 2026-07-08 15:49:51 -03:00
Alex Hart 1bad283af2 Update translations and other static files. 2026-07-08 15:48:59 -03:00
Greyson Parrelli 573013be5c Inline the groupTerminateSend flag. 2026-07-08 15:37:49 -03:00
Greyson Parrelli ca7762c3d0 Fix some more remote restore bugs in regV5. 2026-07-08 15:37:49 -03:00
Michelle Tang e45a4476d3 Turn on group search membership and disappearing calls. 2026-07-08 15:37:48 -03:00
Michelle Tang 636247f83a Update group membership search. 2026-07-08 15:37:48 -03:00
Greyson Parrelli cc72a2232e Fix some reglock bugs in regV5. 2026-07-08 15:37:48 -03:00
Paul Ohlhauser 80f6795665 Fix duplicate clipboard popup.
Co-authored-by: Leo Heitmann Ruiz <leo@heitmannruiz.org>

Closes signalapp/Signal-Android#14871
2026-07-08 15:37:48 -03:00
Alex Hart b55a8dc98e Utilize helper method when constructing proto charge failure.
Fixes #14871
2026-07-08 09:26:37 -03:00
Jeffrey Starke 4ab4bb458a Migrate stories tab to nav 3. 2026-07-07 17:10:23 -04:00
Greyson Parrelli 566a72ecf7 Fix padding on 'wrong number' button in regV5. 2026-07-07 17:00:41 -04:00
Greyson Parrelli 355332d604 Handle additional error cases in regV5. 2026-07-07 16:56:48 -04:00
Greyson Parrelli d8e77d8827 Fix RegV5 captcha WebView always rendering in dark theme. 2026-07-07 15:55:51 -04:00
Greyson Parrelli f5e48a3ff9 Reset regV5 flow state when re-registering. 2026-07-07 15:52:53 -04:00
Greyson Parrelli 46006cbfde Add a 'having trouble' support bottom sheet in regV5. 2026-07-07 15:47:53 -04:00
Greyson Parrelli d695cd8b5a Fix regV5 SMS auto-fill and paste. 2026-07-07 15:38:21 -04:00
Greyson Parrelli debed5f537 Fix proxy config in regV5. 2026-07-07 15:20:51 -04:00
Cody Henthorne ecbdde592e Add relink support and fix sad paths in all link flows.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-07-07 15:17:35 -04:00
Alex Hart 384344c91b Upload media restored from local backups to the archive CDN. 2026-07-07 16:08:51 -03:00
Greyson Parrelli e687718ec3 Disable enter-to-submit for invalid phone numbers in regV5. 2026-07-07 15:07:03 -04:00
Greyson Parrelli cf394aeee8 Update verification code entry UX in regV5. 2026-07-07 13:31:04 -04:00
Greyson Parrelli 8b4a38a2c6 Improve digit interactions on regV5 verification screen. 2026-07-07 13:31:00 -04:00
Greyson Parrelli 1358fefeee Update regV5 country picker UI. 2026-07-07 11:56:45 -04:00
Greyson Parrelli 958236467f Properly persist registrationId in regV5. 2026-07-07 11:56:45 -04:00
Greyson Parrelli c638af6671 Removed unused regV5 field. 2026-07-07 11:56:45 -04:00
Greyson Parrelli bdbe69c7b0 Persist regV5 scratch data with BlobProvider. 2026-07-07 11:56:45 -04:00
dweep 184e0bc3c2 Fix black bars around circular notification avatars
Uses the app's own square contact_photo_target_size dimension for
the Glide submit() call instead of the OEM-overridable system
dimensions android.R.dimen.notification_large_icon_width/height.
Those system dimensions aren't guaranteed to be square, so
CircleCrop() could produce a rectangular bitmap with transparent
letterbox padding, which some OEM skins render as black bars
instead of transparency.

Fixes #14811

Closes signalapp/Signal-Android#14867
2026-07-07 11:20:56 -03:00
Greyson Parrelli 6893a0abc0 Fix lifecycle of regV5 scratch storage. 2026-07-06 15:54:16 -04:00
Greyson Parrelli 74b431d988 Move some usages of Dispatchers.IO to Dispatchers.Default. 2026-07-06 15:29:06 -04:00
dependabot[bot] f6c7824050 ci: bump docker/build-push-action from 6.18.0 to 7.3.0
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 15:28:38 -04:00
dependabot[bot] b3904982a5 ci: bump docker/setup-buildx-action from 3.11.1 to 4.2.0
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 15:28:00 -04:00
Alex Hart 5f1a7397e1 Fix donation test. 2026-07-06 15:04:40 -03:00
Greyson Parrelli 55941d3ccc Use static timezone for RemoteRestoreScreenScreenshotTests. 2026-07-06 13:39:28 -03:00
Michelle Tang 0b92bc41fe Add invite and add member to group search. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 2373f567d8 Include all build reports and test results in Github CI artifacts. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 7e81240d4d Add a gradle task for firebase test lab. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 5cf61665d4 Add unit test coverage for regV5 screens. 2026-07-06 13:39:28 -03:00
Greyson Parrelli 9814c520e2 Add screenshot tests for all of regV5. 2026-07-06 13:39:28 -03:00
Cody Henthorne 6664716930 Fix crash tapping not-yet-restored media in conversation settings shared media rail. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 6194acfdba Fix some edge-to-edge stuff with regV5. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 8db4a5238c Make regV5 available for internal users. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 60192c4b46 Fix icons and restore options in regV5. 2026-07-06 13:39:25 -03:00
Cody Henthorne 512df1ec1b Implement phone friendly link device registration flow. 2026-07-06 13:39:25 -03:00
Michelle Tang a849af6880 Add more features to group member search. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 3d0a743fe3 Constrain recipient search bar placeholder to a single line. 2026-07-06 13:39:25 -03:00
Greyson Parrelli f5e065d050 Cleanup regV5 backstack. 2026-07-06 13:39:25 -03:00
Greyson Parrelli 7e26badb4c Support automatic SMS fill in regV5. 2026-07-06 13:39:24 -03:00
Greyson Parrelli da8af40e4f Allow 'next monday' schedule for all users. 2026-07-06 13:39:24 -03:00
Greyson Parrelli 364f2baf4b Label scheduled-message times beyond tomorrow with their weekday. 2026-07-06 13:39:24 -03:00
Greyson Parrelli 97a0f7b659 Fix dependency initialization order. 2026-07-06 13:39:24 -03:00
Alex Hart 310e90be84 Harden donation checkout instrumentation tests. 2026-07-06 13:39:24 -03:00
Cody Henthorne b462ef2149 Allow banners to reactively update their enabled state. 2026-07-06 13:39:24 -03:00
Cody Henthorne cd5ead4981 Update unregistered UI for linked devices. 2026-07-06 13:39:24 -03:00
Cody Henthorne 9be6f488c4 Add warning dialogs when mixing local backups and optimize media. 2026-07-06 13:39:23 -03:00
Alex Hart 5a6b0cbfef Bump version to 8.18.2 2026-07-06 13:35:58 -03:00
Alex Hart 86169b7368 Update translations and other static files. 2026-07-06 13:31:40 -03:00
Michelle Tang d4d8b54232 Disable disappearing calls. 2026-07-06 11:20:02 -04:00
Michelle Tang e74ed9dbf3 Bump version to 8.18.1 2026-07-02 14:55:25 -04:00
Michelle Tang e1687715d3 Update baseline profile. 2026-07-02 14:44:57 -04:00
Michelle Tang 447f29521b Update translations and other static files. 2026-07-02 14:36:19 -04:00
Greyson Parrelli c029d12b22 Fix long-text attachments failing media constraint check when sending long messages. 2026-07-02 12:03:24 -03:00
Michelle Tang 417c0f98ad Bump version to 8.18.0 2026-07-01 15:45:37 -04:00
Michelle Tang be41a24c0c Update baseline profile. 2026-07-01 15:34:34 -04:00
Michelle Tang b95815c963 Update translations and other static files. 2026-07-01 15:25:17 -04:00
Michelle Tang 15d86c9f13 Enable KT. 2026-07-01 15:11:49 -04:00
Michelle Tang 5c233daf38 Enable disappearing calls. 2026-07-01 15:11:49 -04:00
Greyson Parrelli b91ee27620 Don't enable TLS 1.3 on devices that don't support it. 2026-07-01 15:11:49 -04:00
Jeffrey Starke c03ae68ef2 Fix system navigation bar color on older Android versions. 2026-07-01 15:11:49 -04:00
Alex Hart b6f6d1e42f Add spinner API support for inserting attachments. 2026-07-01 15:11:49 -04:00
Greyson Parrelli db899e92f5 Add contact support capability to regV5. 2026-07-01 15:11:49 -04:00
Greyson Parrelli 99b89743a7 Fix default text field colorings. 2026-07-01 15:11:49 -04:00
Cody Henthorne 2bc2ae2867 Change optimize on-device storage to Optimize Signal storage. 2026-07-01 15:11:48 -04:00
Michelle Tang 300e5bf6f6 Various fixes for disappearing calls. 2026-07-01 15:11:48 -04:00
Michelle Tang 17d6fe918d Enable 1:1 polls. 2026-07-01 15:11:48 -04:00
Greyson Parrelli e71c4734bd Fix regV5 QR code animation. 2026-07-01 15:11:48 -04:00
Jim Gustafson 7d39ad04e3 Disable custom audio config by default for internal users. 2026-07-01 15:11:48 -04:00
Cody Henthorne 1ee4c590b7 Bump java and kotlin target to JDK 21. 2026-07-01 15:10:24 -04:00
Greyson Parrelli 4a0325ea55 Fix restoration of PIN during remote restore in regV5. 2026-07-01 15:10:24 -04:00
Cody Henthorne c4faafa9ae Use device specific API for linked devices updating capabilities. 2026-07-01 15:10:24 -04:00
Cody Henthorne ee11d1fd3b Harden and improve change number flow. 2026-07-01 15:10:24 -04:00
Greyson Parrelli c1c6db2b3b Update dockerfile to latest ubuntu snapshot. 2026-07-01 15:10:24 -04:00
Alex Hart 5372814468 Hook in QR Scans in new media send flow. 2026-07-01 15:10:23 -04:00
Alex Hart e5ed84f08c Update field access properties. 2026-07-01 15:10:23 -04:00
Alex Hart ab90969b0e Show donation thanks sheet reliably after redirect-based (iDEAL/Wero) donations. 2026-07-01 15:10:23 -04:00
Greyson Parrelli ebd64305bb Add button to help enter AEP from password manager in regV5. 2026-07-01 15:10:23 -04:00
Alex Hart f3b7368043 Add donation permit failure tests and surface pre-charge errors as payment setup errors. 2026-07-01 15:10:23 -04:00
Cody Henthorne a7de53333d Enable linked device flow for debug builds. 2026-07-01 15:10:23 -04:00
Cody Henthorne 36898a1e3a Remove extra option on device link screen. 2026-07-01 15:10:23 -04:00
Michelle Tang ec18fb4317 Add group search membership. 2026-07-01 15:10:23 -04:00
Greyson Parrelli dd671a8489 Fix empty state in story archive. 2026-07-01 15:10:22 -04:00
dependabot[bot] bcb5c37ed6 ci: bump the actions group.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 15:10:22 -04:00
Greyson Parrelli a4a6117788 Improve remote restore test coverage in regV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli bd42444e00 Improve device transfer test coverage in regV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli b10cd9c5d3 Ensure device transfer flag is reset when re-registering. 2026-07-01 15:10:22 -04:00
Greyson Parrelli b87abdabfa Improve PIN loading spinners in regV5. 2026-07-01 15:10:22 -04:00
Cody Henthorne 91965d205a Add device linking support to RegV5. 2026-07-01 15:10:22 -04:00
Greyson Parrelli 55b5997a4e Improve UX for missing SVR data in regV5. 2026-07-01 15:10:21 -04:00
Greyson Parrelli bdb6b7d5f1 Use a more obvious error code for DeviceTransferBlockingInterceptor. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 97740b34eb Ensure tha regV5 is used for re-reg. 2026-07-01 15:10:21 -04:00
Jeffrey Starke 5ed1bdc26a Migrate calls tab to nav 3. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 7505ef1834 Fix CDN not being copied on duplicate attachment. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 07afbb5a8f Hide edit option for messages with undownloaded long-text attachments. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 613a07eb2c Optimize single name collision check. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 0943030efa Split update into two statements to better utilize indices. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 5b266081db Preserve video duration when populating media metadata. 2026-07-01 15:10:21 -04:00
Greyson Parrelli 8536504318 Stop prompting system password restore on PIN entry in regV5. 2026-07-01 15:10:20 -04:00
Greyson Parrelli 495b5ef9be Fix BackupSubscriptionCheckJobTest. 2026-07-01 15:10:20 -04:00
Greyson Parrelli bfdc628dd2 Fix skip button during PIN entry in regV5. 2026-07-01 15:10:20 -04:00
Greyson Parrelli 80aa521e42 Add proxy setting to regV5. 2026-07-01 15:10:18 -04:00
Greyson Parrelli 9f1c8961f0 Deduplicate sticker search results when querying by emoji. 2026-07-01 15:09:54 -04:00
Greyson Parrelli 8eeffff923 Enable regV5 on debug builds. 2026-07-01 15:09:54 -04:00
Greyson Parrelli e07a3e05bf Store restoreDecision separately in regV5. 2026-07-01 15:09:54 -04:00
Alex Hart 2c35a6a3fe Add ability to take a photo in media-send feature module. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 248a787d74 Keep the universalSealedSender setting on re-reg in regV5. 2026-06-29 12:13:01 -04:00
Cody Henthorne e76a584bb7 Add additional InvalidSessionException checking to 1:1 send flows. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 5a9cf128a0 Fix string in regV5 captcha screen. 2026-06-29 12:13:01 -04:00
Greyson Parrelli c7ac7087f7 Fix updateVerificationMetadata task to account for IDE tooling. 2026-06-29 12:13:01 -04:00
jeffrey-signal 9e2be2796b Fix gradle sync failure. 2026-06-29 12:13:01 -04:00
adel-signal 3f850375c1 Update RingRTC version to v2.69.5 2026-06-29 12:13:01 -04:00
Greyson Parrelli c91b739c1b Improve bubble UI for sticker replies. 2026-06-29 12:13:01 -04:00
Greyson Parrelli 600acb82bd Move improved notification management to Labs. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 7d6a31b254 Allow sending sticker replies behind a Labs flag. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 56378c7166 Make lower hand suggestions available for external users. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 987f5158d0 Add docker caching to speed up CI task. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 76a1d89954 Update verification metadata task to trim unused entries. 2026-06-29 12:13:00 -04:00
Michelle Tang 72fd5d30df Fix typo. 2026-06-29 12:13:00 -04:00
Greyson Parrelli f74f0e5109 MaybeMarkRegistrationComplete for regV5. 2026-06-29 12:13:00 -04:00
Greyson Parrelli 6e03627c47 Fix notification prompts in regV5. 2026-06-29 12:12:59 -04:00
Michelle Tang 837598657d Bump version to 8.17.3 2026-06-29 12:02:37 -04:00
Michelle Tang cf5aa92f84 Update baseline profile. 2026-06-29 11:57:52 -04:00
Michelle Tang 1613adf088 Update translations and other static files. 2026-06-29 11:46:34 -04:00
Cody Henthorne cc01d39240 Fix missing notifications for missed one-to-one calls. 2026-06-29 10:17:43 -04:00
Cody Henthorne b29bae50af Attempt to recover from bad change number state. 2026-06-29 09:45:36 -04:00
jeffrey-signal fa8386c881 Bump version to 8.17.2 2026-06-26 13:38:30 -04:00
jeffrey-signal b6d0e5d701 Update baseline profile. 2026-06-26 13:33:45 -04:00
jeffrey-signal ae69714346 Update translations and other static files. 2026-06-26 13:27:37 -04:00
Greyson Parrelli 87fecc7379 Skip per-folder unread queries when only the default chat folder exists. 2026-06-26 13:00:59 -04:00
Greyson Parrelli 1560be0300 Guard against null intent extras in MainActivity conversation handling. 2026-06-26 12:53:48 -04:00
Jeffrey Starke b652e2d5f9 Fix empty detail screen showing after exiting a conversation in single pane mode.
Resolves https://github.com/signalapp/Signal-Android/issues/14850
2026-06-26 12:53:47 -04:00
Cody Henthorne 18b8e387ce Fix crash when restoring compose view stubs with saved state. 2026-06-26 12:23:55 -04:00
jeffrey-signal f089aa41cf Bump version to 8.17.1 2026-06-25 16:33:25 -04:00
jeffrey-signal d9930cc307 Update baseline profile. 2026-06-25 15:52:08 -04:00
jeffrey-signal 576948f5ac Update translations and other static files. 2026-06-25 15:45:29 -04:00
Alex Hart ed4c298c33 Implement blind donation permits system. 2026-06-25 13:10:11 -03:00
Michelle Tang 0fab27fee9 Update delete string. 2026-06-25 10:56:22 -04:00
Cody Henthorne 77c73dd493 Avoid requireActivity() on background thread when deleting conversations. 2026-06-24 16:33:25 -04:00
jeffrey-signal c7a8f58f29 Bump version to 8.17.0 2026-06-24 15:18:44 -04:00
jeffrey-signal 9a12e84ad4 Update baseline profile. 2026-06-24 15:06:19 -04:00
jeffrey-signal e500dd0283 Update translations and other static files. 2026-06-24 14:55:55 -04:00
Cody Henthorne aa9ba9c668 Fix crash when retrying a failed restore of offloaded media. 2026-06-24 14:49:54 -04:00
Greyson Parrelli 5553ef6a99 Add some slack to screenshot tests. 2026-06-24 14:49:53 -04:00
Jeffrey Starke 141a128429 Prevent conversation settings screens from stacking when switching recipients. 2026-06-24 14:49:53 -04:00
Jeffrey Starke 52750e726a Fix active chat highlighting when navigating directly to conversation settings. 2026-06-24 14:49:53 -04:00
Alex Hart 97897a84aa Gate "Allow sealed sender from anyone" to primary device. 2026-06-24 14:49:53 -04:00
Michelle Tang 78ad67baad Fix spinner build. 2026-06-24 14:49:52 -04:00
Michelle Tang 13aafbfefd Add bio auth when viewing a recovery key. 2026-06-24 14:49:52 -04:00
Jeffrey Starke 22dddeb3b7 Hide delete button on call link details for non-admin users. 2026-06-24 14:49:52 -04:00
Michelle Tang 5c64a91864 Present the backup key as a passphrase. 2026-06-24 14:49:52 -04:00
Michelle Tang 66b6c1656c Rotate send admin delete config. 2026-06-24 14:49:51 -04:00
Michelle Tang 44d1c8c4bb Check for deletion in edit message. 2026-06-24 14:49:51 -04:00
Alex Hart 599d55ac0b Fix send button default color. 2026-06-24 14:49:51 -04:00
Greyson Parrelli 9cbe204141 Auto-populate phone number in regV5. 2026-06-24 14:49:51 -04:00
andrew-signal 8615dfc463 Bump libsignal to v0.96.3 2026-06-24 14:49:50 -04:00
Greyson Parrelli 72050dbe64 Make same-group-membership helper available for all users. 2026-06-24 14:49:50 -04:00
andrew-signal 9dac02fa1c Bump libsignal to v0.96.2
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-06-24 14:49:50 -04:00
Alex Hart 7fdaf6f706 Add dontations screenshot tests. 2026-06-24 14:49:50 -04:00
Alex Hart 4316136723 Move BlobProvider and supporting classes to core-util. 2026-06-24 14:49:49 -04:00
Greyson Parrelli f375750cf8 Fix storageCapable bug in regV5. 2026-06-24 14:49:49 -04:00
Greyson Parrelli c55f281213 Improve pin creation UX in regV5. 2026-06-24 14:49:49 -04:00
Greyson Parrelli 64496d1d92 Fix regV5 integration points. 2026-06-24 14:49:48 -04:00
Greyson Parrelli 8eac4d3a57 Improve regV5 logging. 2026-06-24 14:49:48 -04:00
Greyson Parrelli 7b5f7cd808 Add pin opt-out support to regV5. 2026-06-24 14:49:48 -04:00
Greyson Parrelli 5b99c6681c Add new updateVerificationMetadata task. 2026-06-24 14:49:48 -04:00
dependabot[bot] f989ad4014 ci: bump the actions group with 2 updates
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 14:49:47 -04:00
dependabot[bot] ce5c023f3b Bump cryptography from 48.0.0 to 48.0.1 in /reproducible-builds/apkdiff
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 14:49:47 -04:00
Cody Henthorne f22567e4fb Ensure video send UX uses same max video constraint as transcoding. 2026-06-24 14:49:47 -04:00
jeffrey-signal cdb73d4b8a Migrate chats tab to nav 3.
- resolves signalapp/Signal-Android#8947
- resolves signalapp/Signal-Android#14777
- resolves signalapp/Signal-Android#14784
- resolves signalapp/Signal-Android#14800
- resolves signalapp/Signal-Android#14803
2026-06-24 14:49:47 -04:00
Greyson Parrelli 48901f64c7 Update plaintext export rules. 2026-06-22 12:46:53 -04:00
Alex Hart ab4a38d565 Move CameraXFragment to feature:media-send. 2026-06-22 10:17:05 -03:00
Greyson Parrelli 4e077bbb52 Fix CallTable null check. 2026-06-22 00:33:33 -04:00
Greyson Parrelli 4f17aa2b17 Add battery logging. 2026-06-22 00:02:29 -04:00
Greyson Parrelli e7808eb842 Improve search performance. 2026-06-18 16:59:03 -04:00
Greyson Parrelli fe0f7ee5e7 Add a search benchmark. 2026-06-18 16:09:12 -04:00
Cody Henthorne c4846d92da Add request backfill attachment support for linked devices. 2026-06-18 16:09:12 -04:00
Alex Hart 83cb48d119 Move the VideoEditorFragment to the media-send feature module. 2026-06-18 16:09:12 -04:00
Cody Henthorne 987f92245d Fix instrumentation tests relying on mocked SignalStore and RemoteConfig. 2026-06-18 16:09:12 -04:00
Alex Hart aecd17b2f0 MediaSelectScreen rework. 2026-06-18 16:09:12 -04:00
Alex Hart a7b4a5d93d Rip out Camera1. 2026-06-18 16:09:12 -04:00
Greyson Parrelli c8d2a06676 Bump version to 8.16.1 2026-06-18 16:08:27 -04:00
Greyson Parrelli 42d114e75b Update baseline profile. 2026-06-18 15:56:52 -04:00
Greyson Parrelli c2abe2fc33 Update translations and other static files. 2026-06-18 15:49:24 -04:00
Michelle Tang fb1c7c346e Turn off KT. 2026-06-18 15:02:55 -04:00
Greyson Parrelli 0b196db4b6 Ensure we always set text size on conversation item. 2026-06-17 16:53:14 -04:00
Greyson Parrelli bdd1858602 Allow text to be selectable in message details. 2026-06-17 15:54:33 -04:00
Cody Henthorne 915181fbb7 Prevent IncomingMessageObserver foreground service crash. 2026-06-17 15:13:36 -04:00
Greyson Parrelli 5f375dc9a6 Don't stop websocket foreground service for unregistered if already running. 2026-06-17 14:25:46 -04:00
Greyson Parrelli 5929866ae0 Bump version to 8.16.0 2026-06-17 13:49:25 -04:00
Greyson Parrelli d706fb0c4b Update baseline profile. 2026-06-17 13:26:59 -04:00
Greyson Parrelli f4185d2868 Update translations and other static files. 2026-06-17 13:17:21 -04:00
Greyson Parrelli 9430c27e64 Setup basic compose screenshot testing infra for regV5. 2026-06-17 13:08:36 -04:00
Michelle Tang b724f2b01a Turn on KT. 2026-06-17 13:08:36 -04:00
Greyson Parrelli 1e6d575ec9 Allow a backoffInterval of zero. 2026-06-17 13:08:36 -04:00
Greyson Parrelli 4c7cf5212e Remove the first PIN reminder interval. 2026-06-17 13:08:36 -04:00
Michelle Tang 33ca1132dc Update deleted messages UI. 2026-06-17 13:08:36 -04:00
andrew-signal a5e11abdc9 Bump to libsignal v0.94.5. 2026-06-17 13:08:36 -04:00
Michelle Tang 3924f65cbe Update group update margins. 2026-06-17 13:08:35 -04:00
Greyson Parrelli c500d8ecbd Prevent crash when popping back stack from a detached EnterCodeFragment. 2026-06-17 13:08:35 -04:00
Greyson Parrelli cd98fd894d Prevent crash when parsing an invalid custom donation amount. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 45a3c44d0c Prevent crash when opting out of PIN after fragment is detached. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 8ddec63e31 Make long text selectable. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 2d2a871194 Always render message details bubbles in no-wallpaper mode. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 2ef0032a33 Revert "Manually draw location on google map."
This reverts commit 02d245ac0c.
2026-06-17 13:08:35 -04:00
Greyson Parrelli 570a310e2e Do not show websocket notification for unregistered users. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 930a263174 Don't show remote mute in 1:1 calls. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 7df015ceef Improve registered check for CheckServiceReachabilityJob. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 4c1555bc7b Add more checks to avoid unnecessary websocket connects. 2026-06-17 13:08:35 -04:00
Greyson Parrelli e877f43dde Fix body range bounds validation for long text messages. 2026-06-16 11:03:55 -04:00
Greyson Parrelli 52dcbb8bc6 Add a separate 'internal issues' notification channel. 2026-06-16 09:56:45 -04:00
Alex Hart e0dd576cb1 Update lint baseline. 2026-06-16 09:56:12 -04:00
Alex Hart fb746b1ad5 Add AAPT OSX verification metadata. 2026-06-16 09:56:12 -04:00
Michelle Tang ef35efe34e Update sync msg disappearing timers for calls. 2026-06-16 09:56:12 -04:00
dependabot[bot] 6a30caff87 Bump gradle/actions from 6.1.0 to 6.2.0 2026-06-16 09:56:12 -04:00
Alex Hart cb2816362c Fix crash when group story has more than 100 replies or reactions. 2026-06-16 09:56:12 -04:00
Alex Hart 5f67c9363e Fix back navigation when opening group settings from the chat list. 2026-06-16 09:56:12 -04:00
Alex Hart ba76a8323e Fix back navigation from conversation settings sub-screens popping to the chat. 2026-06-16 09:56:12 -04:00
Alex Hart aa9c7f7d7b Use detail navigation router instead of finishing activity when deleting conversation. 2026-06-16 09:56:12 -04:00
Greyson Parrelli 411a0198b4 Show media preview controls immediately. 2026-06-16 09:56:12 -04:00
Greyson Parrelli 39679ebfc3 Inline useNewLinkifier flag. 2026-06-16 09:56:12 -04:00
Alex Hart 933b799266 Utilize events instead of callbacks in MediaSend feature module. 2026-06-16 09:56:12 -04:00
Cody Henthorne d22a2c0a50 Fix transfer control progress reporting bugs. 2026-06-16 09:56:12 -04:00
Greyson Parrelli 3f682be609 Upgrade AGP to 9.2.1 2026-06-16 09:56:12 -04:00
Greyson Parrelli b16481616a Fix a bunch of lint issues. 2026-06-16 09:56:12 -04:00
Greyson Parrelli d44bef0eda Move backup status operations off the main thread. 2026-06-16 09:56:12 -04:00
Cody Henthorne f02b8001e4 Increase tap area for start/retry download. 2026-06-16 09:56:12 -04:00
Cody Henthorne fa258dcef2 Use indexes for story viewed-receipt lookup and pinned messages queries. 2026-06-16 09:56:12 -04:00
Michelle Tang fc547218d1 Turn on capability for KT username syncs. 2026-06-16 09:56:12 -04:00
Alex Hart eea29813fa Move DatabaseId and AttachmentId to core.models. 2026-06-16 09:56:12 -04:00
Alex Hart 276d71d365 Decouple add message dialog from old view model. 2026-06-16 09:56:12 -04:00
Cody Henthorne 539276673a Fix flakey BackupDeleteJobTest. 2026-06-16 09:56:12 -04:00
Greyson Parrelli d6871f8dc2 Bump version to 8.15.3 2026-06-15 19:35:19 -04:00
Greyson Parrelli d93543510f Update baseline profile. 2026-06-15 19:35:19 -04:00
Greyson Parrelli 69f7ad28ec Update translations and other static files. 2026-06-15 19:35:19 -04:00
Greyson Parrelli 8c2ff2f1c2 Improve handling of unlinked device during send. 2026-06-15 19:35:19 -04:00
Cody Henthorne 5e8cebdc87 Bump version to 8.15.2 2026-06-12 15:06:49 -04:00
Cody Henthorne c8f9c41cea Update baseline profile. 2026-06-12 15:00:45 -04:00
Cody Henthorne 647dc23de6 Update translations and other static files. 2026-06-12 14:53:10 -04:00
Cody Henthorne b0531247c3 Prevent crash when building shortcuts for large groups. 2026-06-12 14:35:15 -04:00
Greyson Parrelli f08a20d0a6 Render unread divider when the only unread message is the newest in the thread. 2026-06-12 14:10:40 -03:00
Cody Henthorne 16232e2f9f Fix transfer control showing stale data or not responding. 2026-06-12 13:01:18 -04:00
Michelle Tang fc856dd500 Turn off KT. 2026-06-12 11:22:37 -04:00
Alex Hart 73f81075ce Removes second dialog and adds learnmore. 2026-06-12 11:16:19 -03:00
Cody Henthorne c5efb2ce6c Bump version to 8.15.1 2026-06-11 14:20:28 -04:00
Cody Henthorne c7b5738787 Update baseline profile. 2026-06-11 14:13:07 -04:00
Cody Henthorne df88b7fe35 Update translations and other static files. 2026-06-11 14:03:42 -04:00
Cody Henthorne 46213b38e7 Exclude quote attachments from sticker detection during backup export. 2026-06-11 12:26:41 -04:00
Alex Hart f8b53378c6 Remove touch interceptor. 2026-06-11 13:16:23 -03:00
Greyson Parrelli bbb09eb7a0 Debounce search queries on conversation list. 2026-06-10 23:54:55 -04:00
Cody Henthorne 52ceb4062d Reject group call ring updates from senders who are not current group members. 2026-06-10 16:00:59 -04:00
Cody Henthorne f7eaa1cb51 Fix outgoing group calls showing as incoming when someone joins. 2026-06-10 16:00:59 -04:00
Cody Henthorne c62c15c810 Refresh conversation options menu when message request state changes. 2026-06-10 16:00:59 -04:00
Cody Henthorne 2389279cfd Prevent exported settings activity from honoring caller-supplied navigation graph. 2026-06-10 16:00:59 -04:00
Cody Henthorne 3e92fca26d Bump version to 8.15.0 2026-06-10 15:51:38 -04:00
Cody Henthorne 526928ce0a Update baseline profile. 2026-06-10 15:40:12 -04:00
Cody Henthorne 2ea2f561ae Update translations and other static files. 2026-06-10 15:29:25 -04:00
Cody Henthorne c88f048049 Ensure unregistered contacts update storage service if previously registered. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 204a233235 Some improvements to apkdiff. 2026-06-10 15:20:00 -04:00
BarbossHack 2e4abd8ed3 Switch to semantic resources.arsc comparison in apkdiff.
Closes signalapp/Signal-Android#14828
2026-06-10 15:20:00 -04:00
Greyson Parrelli a2a0b11c98 Improve validation on launcher alias intents. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 96f893652b Improve receipt message validations. 2026-06-10 15:20:00 -04:00
Greyson Parrelli d4924d2a13 Ignore START_ROUTE in exported settings activity when launched by another app. 2026-06-10 15:20:00 -04:00
Greyson Parrelli fa6b512cfc Improve link preview validations. 2026-06-10 15:20:00 -04:00
Alex Hart d447af36ba Surface retryable failure for slow donations config load. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 41260f37c9 Fix bug where background connection would drop in forceWebsocket mode.
Relates to #14793
2026-06-10 15:20:00 -04:00
Greyson Parrelli 8950f7f7f9 Simplify media session permission handling. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 14916d068f Allow all group call participants to remote mute, not just admins. 2026-06-10 15:20:00 -04:00
emir-signal 33022baaa2 Update to RingRTC v2.69.3
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-06-10 15:20:00 -04:00
Alex Hart 4cdd1f70ac Fix contact search list flickering on query change. 2026-06-10 15:20:00 -04:00
Alex Hart 9e3ee16e65 Fix dead toolbar navigation arrow in ShareActivity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-10 15:20:00 -04:00
Michelle Tang bf912e14d9 Turn on KT. 2026-06-10 15:20:00 -04:00
Cody Henthorne 56f1a9e0ec Clear SVR auth tokens when disabling the PIN. 2026-06-10 15:20:00 -04:00
Cody Henthorne e468156c4c Verify multiple APNG lengths to prevent bad input from crashing. 2026-06-10 15:20:00 -04:00
Alex Hart 029b91066f Add warnings for phishing. 2026-06-10 15:20:00 -04:00
Alex Hart 5909a1b92a Migrate MultiselectForward to Jetpack Compose. 2026-06-09 17:21:48 -04:00
Cody Henthorne 9478cdf049 Reset SAS for device transfer on reconnect and hard abort if disconnected during transfer. 2026-06-09 17:21:48 -04:00
Michelle Tang 6d260ab63d Update announcement checks. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4bc11fcf0d Rotate IndividuaSendJobV2 remote config. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 33619fe463 Fix IndividualSendJobV2 edit message timestamps. 2026-06-09 17:21:48 -04:00
Cody Henthorne 2b17868797 Skip create remote key copy if duplicate is same attachment. 2026-06-09 17:21:48 -04:00
Alex Hart 582a464a52 Fix screen share video being center-cropped. 2026-06-09 17:21:48 -04:00
Cody Henthorne 812a858761 Reject attachments with unrecognized CDN numbers instead of crashing. 2026-06-09 17:21:48 -04:00
Alex Hart 6ea96795cb Fix detail pane state getting stuck after cancelled predictive back gesture.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:48 -04:00
Michelle Tang 70ab0baa3c Fix broken calling test. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5041057bab Update shouldIngore for edit and decryption error messages. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 6210d1b397 Improve proxy configuration. 2026-06-09 17:21:48 -04:00
Greyson Parrelli f1accae295 Fix bidi balancing character. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5f6d20453c Improve incoming payment validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli d33385d1b2 Improve group story reply and reaction validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 3d05bc3471 Fix rules with group story replies and timers. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0514a5c6c8 Add a hard cap of stickers per pack manifest. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 798ba11e62 Validate style body ranges have a start and length to prevent message processing crash. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 848a61787b Improve device transfer wifi validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli ccfbb27695 Improve body range validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 26b1d3a0f8 Improve username link storage service validation. 2026-06-09 17:21:48 -04:00
Greyson Parrelli c5785c086e Strip the full set of Unicode bidi controls from attachment filenames. 2026-06-09 17:21:48 -04:00
Cody Henthorne 6aeb145024 Require Play Services send permission for SMS retriever broadcasts. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 7e8c6228d8 Improve CDN mismatch reconciliation query. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4c08b94b88 Reduce Recipient usage in group-related jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 135bc6e560 Convert a batch of androidTest db tests to unit tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0ebeb5aa92 Restrict S3 downloads to ReleaseChannel. 2026-06-09 17:21:48 -04:00
Greyson Parrelli aaa7a18190 Remove Robolectric from some tests with light mocking. 2026-06-09 17:21:48 -04:00
Greyson Parrelli bfc1c4ebfa Removed unnecessary Robolectric annotation from some tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli b4cf59f9c2 Reduce Recipient usage in some jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 15e7b30fa1 Add tests for IndividualSendJobV2. 2026-06-09 17:21:47 -04:00
Cody Henthorne a5359e05a3 Convert TransferControlView rendering to compose. 2026-06-09 17:21:47 -04:00
Michelle Tang 73557ae72a Add jitter to KT weekly check. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2505049e39 Allow WebSocketDrainer to run longer if network returns while waiting. 2026-06-09 17:21:47 -04:00
Cody Henthorne a4ae6581ef Improve website apk update flow. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6399a2d899 Avoid taking a transaction in RemappedRecordTables. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 56af57db9e Un-export FCM service. 2026-06-09 17:21:47 -04:00
Greyson Parrelli ddf0de52b1 Log transaction waits as separate issues. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 925e2c1705 Fix potential recursive call. 2026-06-09 17:21:47 -04:00
Greyson Parrelli cb719dff1a Reduce logging verbosity. 2026-06-09 17:21:47 -04:00
Cody Henthorne f2fd3e63c8 Prevent accepted group message request from resetting when restored from storage. 2026-06-09 17:21:47 -04:00
Michelle Tang 8560ab0515 Expire calls from call log. 2026-06-09 17:21:47 -04:00
Michelle Tang d586eff80b Expire group calls. 2026-06-09 17:21:47 -04:00
Alex Hart 27ddd62d7a Keep user in gift flow after payment error instead of finishing the activity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 38f31528ff Fix mob stringification in backups.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Greyson Parrelli 335fcd72f3 Don't flag regV5 PIN entry as a password. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 118231a328 Attempt to restore AccountRecord in regV5. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 754dd15c94 Add device transfer flow to regV5. 2026-06-09 17:21:47 -04:00
Cody Henthorne 566c2d5838 Always scroll to the top of the conversation header when in message request state. 2026-06-09 17:21:47 -04:00
Greyson Parrelli eae894152c Add a new ci task with faster lint. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 53c4069c64 Track perf issues in a table, add internal viewer. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 65082893db Add index for scheduled message queries. 2026-06-09 17:21:47 -04:00
ArseniiS 595364b522 Convert HelpFragment to Compose.
Co-authored-by: Alex Hart <alex@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 7cce504f16 Fix unread divider placement and scroll-to-unread on conversation open. 2026-06-09 17:21:47 -04:00
Alex Hart 337afb11db Add screen size check for link and sync. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6027d58fb5 Fix various lint issues. 2026-06-09 17:21:47 -04:00
jeffrey-signal 132eaa5c70 Adjust tablet preview dimensions to better align with the current WindowBreakpoint config. 2026-06-09 17:21:47 -04:00
jeffrey-signal ba3e15ea6d RegV5 text style and padding fixes. 2026-06-09 17:21:47 -04:00
jeffrey-signal ceee5f714d Adjust RegV5 window insets so screens draw under system bars and handle display cutouts. 2026-06-09 17:21:47 -04:00
jeffrey-signal 4c942f39b0 Elevate RegistrationScaffold footer surface when content scrolls underneath. 2026-06-09 17:21:47 -04:00
jeffrey-signal f3a5bba3f2 Enable TwoPaneRegistrationScaffold panes to scroll independently. 2026-06-09 17:21:47 -04:00
jeffrey-signal 8af7606e3f Use TopAppBar on all RegV5 screens. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2adf84a895 Bump version to 8.14.3 2026-06-09 17:06:45 -04:00
Cody Henthorne 30ed0aa11a Update baseline profile. 2026-06-09 17:01:17 -04:00
Cody Henthorne ec9ae9e3b1 Update translations and other static files. 2026-06-09 16:48:06 -04:00
Cody Henthorne 6a23896077 Fix view state restore crash from LinkPreviewView sharing an id with its ViewStub. 2026-06-09 16:22:57 -04:00
Greyson Parrelli f5a1d79eb5 Ensure we don't run the SVR migration for unregistered users. 2026-06-09 16:20:28 -04:00
Alex Hart 4f0f0938d8 Bump version to 8.14.2 2026-06-05 16:38:39 -03:00
Alex Hart 0136971963 Update translations and other static files. 2026-06-05 16:25:51 -03:00
Michelle Tang f810d731dd Turn off KT. 2026-06-05 14:32:07 -04:00
Cody Henthorne 7c7c364fef Fix sending quoted voice notes in 1:1 chats via IndividualSendJobV2.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-05 14:28:16 -04:00
Alex Hart aa9591211b Bump version to 8.14.1 2026-06-04 16:21:46 -03:00
Alex Hart bbd48547e5 Update translations and other static files. 2026-06-04 16:08:30 -03:00
Cody Henthorne 757b521744 Add additional logging around message request interactions. 2026-06-04 13:59:47 -04:00
Cody Henthorne a6311c87c1 Cleanup bad notified state in background instead of during db migration. 2026-06-04 13:55:38 -04:00
Cody Henthorne 045bd9287b Fix incorrect quote and link preview compose layout. 2026-06-04 11:30:37 -04:00
Cody Henthorne f1a72dd01a Use CDN number instead of parsing identifier for attachment remote id. 2026-06-04 11:22:14 -04:00
Cody Henthorne af4d0a0ef0 Fix illegal session state crashes in receipt send flows. 2026-06-04 11:21:46 -04:00
Greyson Parrelli 7dcaa933f2 Rotate IndividuaSendJobV2 remote config. 2026-06-04 00:36:04 -04:00
Greyson Parrelli 2c88945e6b Fix sending messages to self when your session is deleted. 2026-06-04 00:35:40 -04:00
Greyson Parrelli f9b9ce6c14 Fix character swapping during backup restore. 2026-06-03 23:57:04 -04:00
Michelle Tang 1d8fbad17e Add additional unauthorized KT check. 2026-06-03 17:14:40 -04:00
Alex Hart 6872a14378 Bump version to 8.14.0 2026-06-03 15:18:58 -03:00
Alex Hart 3a1eb4bd88 Update translations and other static files. 2026-06-03 15:11:03 -03:00
Michelle Tang d9f93294e4 Turn on KT. 2026-06-03 15:06:31 -03:00
Greyson Parrelli f063c43b52 Fix session initialization in MessageService. 2026-06-03 15:06:31 -03:00
Michelle Tang a7ed672634 Stop unregistered KT failures. 2026-06-03 15:06:31 -03:00
Michelle Tang 1371663163 Add capability for KT username syncs. 2026-06-03 15:06:31 -03:00
Alex Hart 1f0c24a5d5 Add bank transfer fix for pill size and state readout. 2026-06-03 15:06:31 -03:00
Cody Henthorne b732cbe00b Disable video mirroring for screen share preview in calls. 2026-06-03 15:06:31 -03:00
dependabot[bot] 85d60dd0da Bump GH actions versions for checkout and stale. 2026-06-03 15:06:31 -03:00
andrew-signal c020bfeb7a Bump to libsignal v0.94.4 2026-06-03 15:06:31 -03:00
Greyson Parrelli 3ad446c6c9 Render legacy encryption error messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli bc9c560f96 Add support for optional remote build cache. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 2b54dc4715 Ensure we uppercase AEP entry, add tests. 2026-06-03 15:06:31 -03:00
Michelle Tang 1443457eca Fix pluralization of pin attempt string. 2026-06-03 15:06:31 -03:00
Cody Henthorne ffbc4465bb Improve attachment and delete database operations. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4e5ddad78f Fixed linkifying URLs with commas. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 47a69d667c Hide video overlay stub when binding text-only quoted messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 9d3a51def2 Check battery optimization status before showing general battery saver prompt. 2026-06-03 15:06:31 -03:00
Greyson Parrelli b8c964846c Add gradle cache support to CI. 2026-06-03 15:06:31 -03:00
jeffrey-signal b02210c166 Fix medium sized devices using bar navigation instead of rail navigation. 2026-06-03 15:06:31 -03:00
Greyson Parrelli c2f8261419 Use better index for story query. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 089d47936b Log query analysis of slow queries for internal users. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 48f55bba0a Add search to internal preferences. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 348387f2d0 Fix v2 message sync sends over gRPC. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 30c0ef255a Upgrade wire to 6.4.0 2026-06-03 15:06:31 -03:00
Greyson Parrelli 64d3ba9e5b Add android tools checksum to Dockerfile. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 930a2f052a Remove SDK guard on voice note comparison. 2026-06-03 15:06:31 -03:00
Greyson Parrelli efec070728 Force screen security flag on PIN screens. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 99aa8a602b Force a WAL checkpoint after message deletions. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4a68e0c469 Fix backup progress being stuck. 2026-06-03 15:06:31 -03:00
Michelle Tang be80619a3b Disappear 1:1 calls. 2026-06-03 15:06:31 -03:00
fethij a0d605d1b1 Fix integer division in failed-service-start heuristic 2026-06-03 15:06:31 -03:00
Alex Hart 64f30bff47 Use scrollable tab mode for donation receipt list to prevent text truncation.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 15:06:31 -03:00
Greyson Parrelli 843b656fb6 Use specific remote config for slow database notification. 2026-06-03 15:06:31 -03:00
Cody Henthorne f5f5bf0a67 Use view stubs for heavy, low use compose drafting views. 2026-06-03 15:06:31 -03:00
Cody Henthorne bf73954f42 Add indexes for slow database queries and optimize view-once query. 2026-06-03 13:55:36 -04:00
Cody Henthorne 1fd651ee50 Fix answered ringing group calls getting marked as missed. 2026-06-03 13:55:36 -04:00
Greyson Parrelli f76292769a Update MessageApiV2 to use libsignal-net. 2026-06-03 13:55:36 -04:00
Cody Henthorne 51c4afe5f5 Add missing unidentified status data to individual send v2 sync message. 2026-06-03 13:55:36 -04:00
Cody Henthorne fe435433fd Add missing handler for group request join/cancel update.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 13:55:36 -04:00
andrew-signal fa8098a9aa Bump to libsignal v0.94.2. 2026-06-03 13:55:36 -04:00
BarbossHack 81e09e65cb Fix resources.arsc comparison in reproducible script.
Resolves signalapp/Signal-Android#14817
2026-06-03 13:55:36 -04:00
jeffrey-signal 1059fcafba Fix minimum length hint on RegV5 PIN creation screen. 2026-06-03 13:55:36 -04:00
Cody Henthorne 91d3fa8ad5 Fix bugs around stalled media restore and add recovery logic. 2026-06-03 13:55:36 -04:00
Cody Henthorne 89bffe39ae Improve cold start performance. 2026-06-03 13:55:36 -04:00
Cody Henthorne de2a5ea440 Add internal slow database transaction logging and alerting. 2026-06-03 13:55:36 -04:00
Cody Henthorne a54d62c09d Fix db access on main thread during conversation open. 2026-06-03 13:55:36 -04:00
jeffrey-signal e8785218a5 Add RegV5 confirm PIN UI. 2026-06-03 13:55:36 -04:00
Cody Henthorne e6e6075c9b Fix potential Recipient.resolve on main thread for group thread list item. 2026-06-03 13:55:36 -04:00
Alex Hart 95b69faa58 Cap persisted image-editor undo history to avoid crash. 2026-06-03 13:55:36 -04:00
Cody Henthorne b7f09ef923 Retry delivery receipt send on generic IO failures. 2026-06-03 13:55:36 -04:00
Alex Hart 50884e144e Add linked device registration skeleton UI. 2026-06-03 13:55:35 -04:00
Alex Hart 274feb168e Fix story preview thumbnails not rendering in contact picker. 2026-06-03 13:55:35 -04:00
jeffrey-signal 5b1f5a2a20 Ensure consistent regV5 footer padding and elevation. 2026-06-03 13:55:35 -04:00
dependabot[bot] 2cb9685024 Bump actions/stale from 10.2.0 to 10.3.0 in the actions group.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 13:55:35 -04:00
Greyson Parrelli 0f4fc74829 Reduce unit test parallelism. 2026-06-03 13:55:35 -04:00
Cody Henthorne f5e8e15785 Add index for notification state query. 2026-06-03 13:55:35 -04:00
jeffrey-signal 3ff1501090 Adaptive PIN entry screen. 2026-06-03 13:55:35 -04:00
Greyson Parrelli 0907898105 Have QA task use debug variants. 2026-06-03 13:55:35 -04:00
Cody Henthorne 5fd8101180 Fix spinner build. 2026-06-03 13:55:35 -04:00
Michelle Tang 7e6ad92ca8 Turn off KT. 2026-06-02 23:58:41 -04:00
Michelle Tang ae2477356b Bump version to 8.13.1 2026-05-29 10:58:26 -04:00
Michelle Tang 8d992fa7db Update baseline profile. 2026-05-29 10:55:07 -04:00
Michelle Tang 71c33fc579 Update translations and other static files. 2026-05-29 10:37:38 -04:00
Greyson Parrelli bdefc274b9 Start using updated character set for AEP. 2026-05-29 09:54:58 -04:00
Greyson Parrelli f926d9c893 Update SVR2 enclave. 2026-05-28 00:23:29 -04:00
Michelle Tang ec02d802f2 Bump version to 8.13.0 2026-05-27 14:52:19 -04:00
Michelle Tang 7925041982 Update translations and other static files. 2026-05-27 14:44:57 -04:00
Michelle Tang de42d748dc Ignore tests. 2026-05-27 14:37:51 -04:00
Michelle Tang 3e1df5b8ac Turn on KT. 2026-05-27 14:08:54 -04:00
Greyson Parrelli f1d722c5cd Fix test dependency import. 2026-05-27 14:08:54 -04:00
Cody Henthorne abcd65603c Fix block/unblock causing ANR in recipient bottom sheet. 2026-05-27 14:08:54 -04:00
Greyson Parrelli 7b23110cac Improve instrumentation setup. 2026-05-27 14:08:54 -04:00
Greyson Parrelli baa4dd3c86 Improve handling of devices without Play Services. 2026-05-27 14:08:54 -04:00
Greyson Parrelli 0beda1e615 Cancel delayed notifications when notification state is empty. 2026-05-27 14:01:29 -04:00
Michelle Tang b3acefdb08 Fix instrumentation tests. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 9600366422 Fix instrumentation tests. 2026-05-27 14:01:29 -04:00
jeffrey-signal d85a57adce Add adaptive layout support to regV5 PIN creation screen. 2026-05-27 14:01:29 -04:00
jeffrey-signal cb1b878198 Add string resources for the PIN entry screen. 2026-05-27 14:01:29 -04:00
jeffrey-signal 4a2e2e0137 Add string resources for the PIN creation screen. 2026-05-27 14:01:29 -04:00
Greyson Parrelli b2f8445e1d Upgrade CameraX to 1.6.1 2026-05-27 14:01:29 -04:00
Alex Hart f5f686fece Add call-link specific network error handling.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
fm-sys 1864534174 Add long press to copy app version code.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>

Closes signalapp/Signal-Android#14795
2026-05-27 14:01:29 -04:00
Alex Hart 54c4bda4f2 Allow local backup restore to proceed regardless of remote restore failure state.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
Alex Hart 3cb61e3e8a Remove inaccurate recovery key mention from local backup restore dialog.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 14:01:29 -04:00
andrew-signal 83a83f65ef Prune some unused resources. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 122e1770b7 Hide keyboard on contact selection when add-message is disabled. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 68eb4d3c82 Propagate force flag to story attachment downloads. 2026-05-27 14:01:29 -04:00
Greyson Parrelli ce4d68a20f Filter shortcode numbers in externalContact to prevent E164 re-formatting crash. 2026-05-27 14:01:29 -04:00
Greyson Parrelli 5d45914a08 Improved send performance. 2026-05-27 14:01:29 -04:00
Greyson Parrelli b2f450d849 Add index to make marking unread faster. 2026-05-27 14:01:29 -04:00
Cody Henthorne ed73dc2c24 Remove system call triggering location request. 2026-05-27 14:01:28 -04:00
andrew-signal 71967b278e Remove cluster of orphaned code around CameraContactSelectionViewController. 2026-05-27 14:01:28 -04:00
andrew-signal faadc9854f Re-encode call ringing/busy tones from MP3 to opus. 2026-05-27 14:01:28 -04:00
Greyson Parrelli f206487ede Add MessageService and IndividualSendJobV2. 2026-05-27 14:01:28 -04:00
Greyson Parrelli 0284da2d0f Move a lot of utils into core. 2026-05-27 14:01:28 -04:00
Alex Hart 15a3a8efde Add debug log pop (8 clicks) for reg v5.
Co-authored-by: Michelle Tang <mtang@signal.org>
2026-05-27 14:01:28 -04:00
andrew-signal b429d08c5e Exclude libsignal-testing.md acknowledgment from non-test builds. 2026-05-27 14:01:28 -04:00
Jim Gustafson 3892113a0e Update to RingRTC v2.69.2 2026-05-27 14:01:28 -04:00
Cody Henthorne 41f52ed886 Fix thrashing storage service fields. 2026-05-27 14:01:28 -04:00
Cody Henthorne ec07b7805e Fix incorrect sync message being sent when network drops group send response. 2026-05-27 14:01:28 -04:00
Alexandria 333e514a2f Fix media review confirm button color.
Closes signalapp/Signal-Android#14683
2026-05-27 14:01:28 -04:00
Michelle Tang 15a17adf1a Bump version to 8.12.4 2026-05-27 13:53:20 -04:00
Michelle Tang 13888bab0a Update translations and other static files. 2026-05-27 13:24:56 -04:00
Alex Hart 16fc81f715 Use bitmap avatar icons on API 36+ to avoid BadForegroundServiceNotificationException.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-27 13:03:24 -04:00
Alex Hart b4f2d8682f Always load avatar in the background. 2026-05-27 12:54:11 -03:00
Michelle Tang a05ce88bc0 Bump version to 8.12.3 2026-05-26 16:17:51 -04:00
Michelle Tang 59c38147c7 Update translations and other static files. 2026-05-26 16:07:46 -04:00
Michelle Tang da85d0b4eb Turn off KT. 2026-05-26 15:34:19 -04:00
Cody Henthorne 5aa7e3a7c1 Only take remote release note sync data if present. 2026-05-26 12:33:42 -04:00
Alex Hart c92b0505df Move ContactSearchPagedDataSource.size() off the main thread to fix ANR.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-26 12:55:50 -03:00
Michelle Tang ca8c494fc4 Reset KT for username changes in storage service. 2026-05-26 11:43:39 -04:00
Cody Henthorne fbbcadf09b Bump version to 8.12.2 2026-05-24 14:05:47 -04:00
Cody Henthorne 3fc6ac3871 Update translations and other static files. 2026-05-24 13:56:15 -04:00
Cody Henthorne 6a2ec01c52 Fix duplicate key crash in contact search lists. 2026-05-24 13:29:41 -04:00
Cody Henthorne c1b3fb6d1b Fix profile fetch crash when skipping debounce. 2026-05-24 10:32:58 -04:00
jeffrey-signal 792d86f4d8 Bump version to 8.12.1 2026-05-21 17:06:28 -04:00
jeffrey-signal 849856cde8 Update baseline profile. 2026-05-21 16:58:59 -04:00
jeffrey-signal 0646418d4d Update translations and other static files. 2026-05-21 16:36:58 -04:00
Alex Hart ed540a2f9e Update model key entry. 2026-05-21 16:57:14 -03:00
Cody Henthorne 00d86101f5 Fix crash when no recent recipients for profile refresh.
Fixes #14791
2026-05-21 15:38:37 -04:00
jeffrey-signal 86e49cd564 Bump version to 8.12.0 2026-05-21 11:12:07 -04:00
jeffrey-signal 21b7d64fcd Update baseline profile. 2026-05-21 10:50:28 -04:00
jeffrey-signal 42c0044096 Update translations and other static files. 2026-05-21 10:43:07 -04:00
Michelle Tang f2e8b83604 Update reg v5 UI for quick restore. 2026-05-21 10:38:23 -04:00
Michelle Tang 46b8ac6561 Update reg v5 UI for local backup v1 account. 2026-05-21 10:38:22 -04:00
Michelle Tang 9089cc393e Update reg v5 UI for locked account. 2026-05-21 10:38:22 -04:00
Alex Hart 2ea59bef68 Handle PniChangeNumber sync on linked devices. 2026-05-21 10:38:22 -04:00
Alex Hart 698fc38aed Migrate ContactSearch RV to MappingLazyColumn. 2026-05-21 10:38:22 -04:00
Michelle Tang 1d74b00b91 Update local backups reg v5 UI. 2026-05-21 10:38:21 -04:00
Greyson Parrelli ea861fff49 Remove unnecessary link test. 2026-05-21 10:38:21 -04:00
Cody Henthorne 3b93edcdaf Add verification code requested alert handling. 2026-05-21 10:38:21 -04:00
Cody Henthorne 6722a28f98 Fix broken linkify unit tests. 2026-05-21 10:38:21 -04:00
Cody Henthorne 16de2efa9e Fix non-instrumentation variants not being able to run unit tests. 2026-05-21 10:38:20 -04:00
Michelle Tang 4d0919c9a8 Fix minor safety number UI issues. 2026-05-21 10:38:20 -04:00
Greyson Parrelli 01e1cb4d67 Hide keyboard before navigating to chat settings. 2026-05-21 10:38:20 -04:00
Michelle Tang 9d1d5142da Turn on key transparency. 2026-05-21 10:38:20 -04:00
Alex Hart 49f0c2502b Add IncomingMessageObserver integration test infrastructure. 2026-05-21 10:38:19 -04:00
Michelle Tang 71ffc36e7f Update key transparency string. 2026-05-21 10:38:19 -04:00
Greyson Parrelli 5941ff814d Improve perf for backup info in debuglogs. 2026-05-21 10:38:19 -04:00
Cody Henthorne 1661f3b5f7 Improve profile fetch performance for large groups. 2026-05-21 10:38:18 -04:00
Cody Henthorne d682de08d2 Sort pinned chats like chat list for shortcut list. 2026-05-21 10:38:18 -04:00
Cody Henthorne 5321f8124a Add additional download checks to long message text attachments. 2026-05-21 10:38:18 -04:00
Michelle Tang 5052f22d44 Fix local restore crash from file selection. 2026-05-21 10:38:18 -04:00
Michelle Tang 9a8cb1785b Update reg v5 to use new scaffold. 2026-05-21 10:38:17 -04:00
Cody Henthorne a2065becdd fixup! Fix chat bubbles not rendering due to ConstraintLayout bug. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e85637a58d Inline useBinaryId remote config. 2026-05-21 10:38:17 -04:00
Greyson Parrelli 73c3d141e3 Remove USE_STRING_ID build config. 2026-05-21 10:38:17 -04:00
jeffrey-signal eafba156ba RegistrationScaffold will automatically add top padding when there is no header. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e6beafd612 Update video demo project to support batch transcoding. 2026-05-21 10:38:16 -04:00
Cody Henthorne a9649fd017 Enforce change number post registration delay. 2026-05-21 10:38:16 -04:00
adel-signal 4decae274b Update to RingRTC v2.69.1 2026-05-21 10:38:16 -04:00
gram-signal dbb83d86e3 Add remote config for requirePqRatio. 2026-05-21 10:38:15 -04:00
Greyson Parrelli 2aa27df95b Add SignalRestClient. 2026-05-21 10:38:15 -04:00
Cody Henthorne ec47b83f76 Add sync message encrypt local metric to send flows. 2026-05-21 10:38:15 -04:00
Cody Henthorne 6eea4ba937 Sync release note channel settings with storage service. 2026-05-21 10:38:15 -04:00
Cody Henthorne 9f608337f1 Add friendly toasts for forced remote config refresh. 2026-05-21 10:38:14 -04:00
jeffrey-signal 28edcdf62d Update regV5 permissions screen to use RegistrationScaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 10d969ea35 Add two pane registration scaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 38bac16640 Add separate window breakpoints for windows with large widths vs large heights. 2026-05-21 10:36:50 -04:00
jeffrey-signal 93077ac457 Bump version to 8.11.4 2026-05-21 10:24:18 -04:00
jeffrey-signal c069eb1b88 Update baseline profile. 2026-05-21 10:08:50 -04:00
jeffrey-signal e5cd18bf1e Update translations and other static files. 2026-05-21 10:02:16 -04:00
Alex Hart 9e8ae7e26a Only update desktop activity timestamp for user-initiated sync messages.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-20 12:42:52 -03:00
Cody Henthorne 00042b9579 Stop screen sharing when disabled from system UI. 2026-05-20 10:06:18 -04:00
Greyson Parrelli e750b81a31 Disable dnsjava hosts file parsing to fix NPE race condition. 2026-05-20 10:05:39 -04:00
Greyson Parrelli daec317f52 Don't auto-snooze donation megaphones. 2026-05-20 10:02:35 -04:00
Greyson Parrelli 112514c221 Remove persistent play services error notification.
Fixes #14786
2026-05-19 18:05:05 -04:00
Cody Henthorne f43db8ace0 Fix chat bubbles not rendering due to ConstraintLayout bug.
Resolves signalapp/Signal-Android#14774
2026-05-19 18:05:04 -04:00
jeffrey-signal 54df95727b Bump version to 8.11.3 2026-05-19 12:12:47 -04:00
jeffrey-signal 022b4d9508 Update baseline profile. 2026-05-19 11:58:37 -04:00
jeffrey-signal 7411e725ec Update translations and other static files. 2026-05-19 11:51:57 -04:00
Alex Hart 83a279f422 Fix display bug with donation type. 2026-05-19 11:42:12 -04:00
Michelle Tang 523066d093 Turn off key transparency. 2026-05-19 10:39:06 -04:00
Michelle Tang de27343c24 Update key transparency api. 2026-05-19 10:38:08 -04:00
Michelle Tang c36179293e Fix missing safety number dialog. 2026-05-18 14:13:59 -04:00
andrew-signal a79a91bafb Bump to libsignal v0.94.1 2026-05-18 14:09:27 -04:00
Michelle Tang 13de1ede90 Bump version to 8.11.2 2026-05-15 16:32:54 -04:00
Michelle Tang b94f420393 Update translations and other static files. 2026-05-15 16:29:45 -04:00
Michelle Tang 4909f130cc Animate safety number once. 2026-05-15 16:21:45 -04:00
Michelle Tang 0010386b9e Bump version to 8.11.1 2026-05-14 15:20:56 -04:00
Michelle Tang 02c760945d Update translations and other static files. 2026-05-14 14:58:23 -04:00
Greyson Parrelli a0247bb8cc Add R8 keep rule for org.signal.network to fix Jackson deserialization. 2026-05-14 14:43:36 -04:00
Greyson Parrelli bcfec5de50 Fix benchmark build. 2026-05-14 14:36:10 -04:00
Cody Henthorne b2215915ef Switch to speaker and disable proximity when screen sharing. 2026-05-14 14:36:10 -04:00
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
2634 changed files with 138893 additions and 97204 deletions
+1
View File
@@ -1 +1,2 @@
*.ai binary
**/src/screenshotTest*/reference/**/*.png filter=lfs diff=lfs merge=lfs -text
+29 -9
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- 'main'
- '7.**'
- '8.**'
permissions:
contents: read # to fetch code (actions/checkout)
@@ -16,25 +16,42 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
lfs: true
- name: set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
- name: set up JDK 21
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
cache: gradle
java-version: 21
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
with:
# Only 8.** branch builds write to the cache; everything else (PRs, etc.) reads only.
cache-read-only: ${{ !startsWith(github.ref, 'refs/heads/8.') }}
# Required to persist the Gradle configuration cache across runs.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# Pull requests run the fast custom linter (ciRemote); pushes to main / 8.x branches run the
# full Android lint (qaRemote). Both include screenshot validation, which is deliberately kept
# out of the local qa/ci tasks because screenshot rendering is host-OS dependent.
- name: Build with Gradle
run: ./gradlew qa
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ciRemote' || 'qaRemote' }}
- name: Archive reports for failed build
if: ${{ failure() }}
@@ -42,4 +59,7 @@ jobs:
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: reports
path: '*/build/reports'
path: |
**/build/reports
**/build/test-results
if-no-files-found: ignore
+16 -8
View File
@@ -16,30 +16,38 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# 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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
- name: set up JDK 21
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
cache: gradle
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # 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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Cache base apk
id: cache-base
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
@@ -53,7 +61,7 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
+10 -4
View File
@@ -11,11 +11,17 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build image
run: |
cd reproducible-builds
docker build -t signal-android .
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: reproducible-builds
tags: signal-android
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test build
run: docker run --memory=12g --rm -v "$(pwd)":/project -w /project signal-android ./gradlew --no-daemon --max-workers=1 -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=512m" -Dkotlin.compiler.execution.strategy=in-process bundlePlayProdRelease
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
actions: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
+1 -1
View File
@@ -1,2 +1,2 @@
java openjdk-17.0.2
java openjdk-21.0.2
uv latest
+213 -25
View File
@@ -2,7 +2,10 @@
import com.android.build.api.artifact.ArtifactTransformationRequest
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.HasAndroidTest
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.process.ExecOperations
import org.gradle.work.DisableCachingByDefault
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import java.time.Instant
@@ -10,12 +13,14 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.Properties
import javax.inject.Inject
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
alias(testLibs.plugins.compose.screenshot)
alias(benchmarkLibs.plugins.baselineprofile)
id("androidx.navigation.safeargs")
id("kotlin-parcelize")
@@ -27,8 +32,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1687
val canonicalVersionName = "8.10.2"
val canonicalVersionCode = 1722
val canonicalVersionName = "8.20.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -56,6 +61,11 @@ val localProperties: Properties? = if (localPropertiesFile.exists()) {
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
val benchmarkBackupFile: String? = localProperties?.getProperty("benchmark.backup.file")
val isInstrumentationTestRun = gradle.startParameter.taskNames.any { taskName ->
val lower = taskName.lowercase()
lower.contains("androidtest") || lower.contains("connectedcheck")
}
val selectableVariants = listOf(
"nightlyProdSpinner",
"nightlyProdPerf",
@@ -68,13 +78,11 @@ val selectableVariants = listOf(
"playProdMocked",
"playProdNonMinifiedMocked",
"playProdBenchmark",
"playProdInstrumentation",
"playProdRelease",
"playStagingDebug",
"playStagingCanary",
"playStagingSpinner",
"playStagingPerf",
"playStagingInstrumentation",
"playStagingRelease",
"playProdQuickstart",
"playStagingQuickstart",
@@ -100,6 +108,9 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).conf
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
source("$projectDir/src/benchmarkShared/java")
}
if (isTestTask && name.contains("AndroidTest")) {
source("$projectDir/src/benchmarkShared/java")
}
}
wire {
@@ -121,15 +132,32 @@ ktlint {
version.set("1.5.0")
}
// ktlint only scans convention source dirs, so the shared dirs added to the compile tasks are
// otherwise skipped. Add them to the base test/androidTest ktlint tasks so ktlintCheck/format cover them.
tasks.withType(org.jlleitschuh.gradle.ktlint.tasks.BaseKtLintCheckTask::class.java).configureEach {
if (name.endsWith("OverTestSourceSet") || name.endsWith("OverAndroidTestSourceSet")) {
source("$projectDir/src/testShared")
}
if (name.endsWith("OverAndroidTestSourceSet")) {
source("$projectDir/src/benchmarkShared/java")
}
}
screenshotTests {
// Fraction of differing pixels tolerated before a screenshot test fails (0.0001 = 0.01%).
imageDifferenceThreshold = 0.0001f
}
android {
namespace = "org.thoughtcrime.securesms"
experimentalProperties["android.experimental.enableScreenshotTest"] = true
buildToolsVersion = libs.versions.buildTools.get()
compileSdkVersion(libs.versions.compileSdk.get())
ndkVersion = libs.versions.ndk.get()
flavorDimensions += listOf("distribution", "environment")
testBuildType = "instrumentation"
android.bundle.language.enableSplit = false
@@ -168,6 +196,7 @@ android {
getByName("androidTest") {
java.srcDir("$projectDir/src/testShared")
java.srcDir("$projectDir/src/benchmarkShared/java")
}
}
@@ -215,6 +244,10 @@ android {
versionCode = (canonicalVersionCode * maxHotfixVersions) + possibleHotfixVersions[currentHotfixVersion]
versionName = canonicalVersionName
if (isInstrumentationTestRun) {
applicationIdSuffix = ".test_run"
}
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
@@ -249,8 +282,8 @@ android {
buildConfigField("String[]", "SIGNAL_CDSI_IPS", rootProject.extra["cdsi_ips"] as String)
buildConfigField("String[]", "SIGNAL_SVR2_IPS", rootProject.extra["svr2_ips"] as String)
buildConfigField("String", "SIGNAL_AGENT", "\"OWA\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\", \"BUkY0I+9+oPgDCn4+Ac6Iu813yvqkDr/ga8DzLxFxuk6\"}")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
@@ -272,7 +305,6 @@ android {
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
buildConfigField("boolean", "USE_STRING_ID", "false")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -288,7 +320,11 @@ android {
}
}
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
testInstrumentationRunner = if (project.hasProperty("imoTests")) {
"org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner"
} else {
"org.thoughtcrime.securesms.testing.SignalTestRunner"
}
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
@@ -337,17 +373,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
@@ -463,8 +488,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\"")
@@ -516,6 +541,9 @@ android {
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
if (variant.enable) {
(variant as? com.android.build.api.variant.HasUnitTestBuilder)?.enableUnitTest = true
}
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Rename APK to include version name
@@ -527,9 +555,10 @@ androidComponents {
transformationRequest.set(renameRequest)
}
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
// Include the test-only library on non-release builds.
if (variant.buildType == "release") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
variant.androidResources.ignoreAssetsPatterns.add("libsignal-testing.md")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
@@ -584,6 +613,43 @@ androidComponents {
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
onVariants(selector().withName("playProdDebug")) { variant ->
val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants
tasks.register<FirebaseTestLabTask>("firebaseTestLab") {
group = "Verification"
description = "Runs the ${variant.name} instrumentation tests on Firebase Test Lab via the gcloud CLI. Run a single class with -Pftl.class=<fqcn>[#method]; override other defaults with -Pftl.* properties."
appApkDirectory.set(variant.artifacts.get(SingleArtifact.APK))
testApkDirectory.set(androidTest.artifacts.get(SingleArtifact.APK))
val deviceOverride = project.providers.gradleProperty("ftl.devices").orNull
devices.set(
deviceOverride?.split(";")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: listOf("model=Pixel2.arm,version=31,locale=en,orientation=portrait")
)
useOrchestrator.set(true)
environmentVariables.set(mapOf("clearPackageData" to "true"))
testTimeout.set(project.providers.gradleProperty("ftl.timeout").getOrElse("30m"))
numFlakyTestAttempts.set(project.providers.gradleProperty("ftl.numFlakyTestAttempts").map { it.toInt() }.getOrElse(1))
gcloudProject.set(project.providers.gradleProperty("ftl.project"))
resultsBucket.set(project.providers.gradleProperty("ftl.resultsBucket"))
resultsDir.set(project.providers.gradleProperty("ftl.resultsDir"))
val testClass = project.providers.gradleProperty("ftl.class").orNull?.takeIf { it.isNotBlank() }
testTargets.set(
if (testClass != null) "class $testClass" else project.providers.gradleProperty("ftl.testTargets").orNull
)
gcloudExecutable.set(project.providers.gradleProperty("ftl.gcloud").getOrElse("gcloud"))
extraArgs.set(
project.providers.gradleProperty("ftl.extraArgs").orNull
?.split(" ")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList()
)
}
}
}
baselineProfile {
@@ -656,6 +722,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.process)
@@ -686,6 +753,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)
@@ -705,6 +773,11 @@ dependencies {
}
implementation(libs.lottie)
implementation(libs.lottie.compose)
// Compose screenshot testing
screenshotTestImplementation(testLibs.compose.screenshot.validation.api)
screenshotTestImplementation(libs.androidx.compose.ui.tooling.core)
screenshotTestImplementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.signal.android.database.sqlcipher)
implementation(libs.androidx.sqlite)
testImplementation(libs.androidx.sqlite.framework)
@@ -714,6 +787,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)
@@ -735,7 +809,7 @@ dependencies {
"canaryImplementation"(libs.square.leakcanary)
"instrumentationImplementation"(libs.androidx.fragment.testing) {
androidTestImplementation(libs.androidx.fragment.testing) {
exclude(group = "androidx.test", module = "core")
}
@@ -768,8 +842,18 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(testLibs.espresso.core)
androidTestImplementation(testLibs.espresso.contrib) {
// espresso-contrib transitively pulls the full checkerframework jar (only its annotations are needed),
// whose MANIFEST.MF collides with other test dependencies during androidTest resource merging.
exclude(group = "org.checkerframework", module = "checker")
// accessibility-test-framework drags in an ancient com.google.protobuf:protobuf-lite:3.0.1 whose
// GeneratedMessageLite wins the merged dex and lacks registerDefaultInstance(Class, GeneratedMessageLite),
// crashing tests at runtime. We only use RecyclerViewActions from contrib, not the accessibility checks.
exclude(group = "com.google.android.apps.common.testing.accessibility.framework")
}
androidTestImplementation(testLibs.androidx.test.core)
androidTestImplementation(testLibs.androidx.test.core.ktx)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
@@ -953,6 +1037,110 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
}
}
/**
* Runs an instrumentation test suite on Firebase Test Lab by shelling out to `gcloud firebase test android run`.
*
* The `gcloud` CLI must be installed and authenticated (`gcloud auth login` and a configured project, or an
* activated service account) before invoking this task.
*/
@DisableCachingByDefault(because = "Executes tests on remote devices; results must never be served from the build cache")
abstract class FirebaseTestLabTask
@Inject
constructor(
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val appApkDirectory: DirectoryProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val testApkDirectory: DirectoryProperty
@get:Input
abstract val devices: ListProperty<String>
@get:Input
abstract val useOrchestrator: Property<Boolean>
@get:Input
abstract val environmentVariables: MapProperty<String, String>
@get:Input
abstract val testTimeout: Property<String>
@get:Input
abstract val numFlakyTestAttempts: Property<Int>
@get:Input
@get:Optional
abstract val gcloudProject: Property<String>
@get:Input
@get:Optional
abstract val resultsBucket: Property<String>
@get:Input
@get:Optional
abstract val resultsDir: Property<String>
@get:Input
@get:Optional
abstract val testTargets: Property<String>
@get:Input
abstract val gcloudExecutable: Property<String>
@get:Input
abstract val extraArgs: ListProperty<String>
@TaskAction
fun run() {
val appApk = findApk(appApkDirectory.get().asFile, "app")
val testApk = findApk(testApkDirectory.get().asFile, "instrumentation test")
val arguments = mutableListOf(
gcloudExecutable.get(),
"firebase", "test", "android", "run",
"--type", "instrumentation",
"--app", appApk.absolutePath,
"--test", testApk.absolutePath,
"--timeout", testTimeout.get(),
"--num-flaky-test-attempts", numFlakyTestAttempts.get().toString()
)
devices.get().forEach { device ->
arguments += listOf("--device", device)
}
if (useOrchestrator.get()) {
arguments += "--use-orchestrator"
}
val environment = environmentVariables.get()
if (environment.isNotEmpty()) {
arguments += "--environment-variables"
arguments += environment.entries.joinToString(",") { "${it.key}=${it.value}" }
}
gcloudProject.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--project", it) }
resultsBucket.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-bucket", it) }
resultsDir.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--results-dir", it) }
testTargets.orNull?.takeIf { it.isNotBlank() }?.let { arguments += listOf("--test-targets", it) }
arguments += extraArgs.get()
logger.lifecycle("Running Firebase Test Lab:\n ${arguments.joinToString(" ")}")
execOperations.exec {
commandLine(arguments)
}
}
private fun findApk(directory: File, label: String): File {
return directory.walkTopDown().firstOrNull { it.isFile && it.extension == "apk" }
?: throw GradleException("No $label APK found under ${directory.absolutePath}. Was the assemble task run?")
}
}
abstract class RenameApkTask : DefaultTask() {
@get:InputFiles
abstract val apkFolder: DirectoryProperty
+425 -37838
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -7,6 +7,12 @@
-keep class org.signal.libsignal.usernames.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.signal.donations.json.** { *; }
-keep class org.signal.network.** { *; }
-keep class org.signal.core.util.crypto.AttachmentSecret { *; }
-keep class org.signal.core.util.crypto.AttachmentSecret$* { *; }
-keep class org.signal.core.util.crypto.KeyStoreHelper$SealedData { *; }
-keep class org.signal.core.util.crypto.KeyStoreHelper$SealedData$* { *; }
-keepclassmembers class ** {
public void onEvent*(**);
}
@@ -16,6 +22,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
@@ -8,9 +9,12 @@ import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.logging.PersistentLogger
import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.testing.TestRemoteConfig
import org.thoughtcrime.securesms.util.Environment
/**
* Application context for running instrumentation tests (aka androidTests).
@@ -19,10 +23,22 @@ 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))
AppDependencies.deadlockDetector.start()
// Stage any test-declared remote config into the store to be read in RemoteConfig.init().
if (TestRemoteConfig.pending.isNotEmpty()) {
val json = TestRemoteConfig.json
SignalStore.remoteConfig.currentConfig = json
SignalStore.remoteConfig.pendingConfig = json
}
}
override fun initializeLogging() {
@@ -0,0 +1,191 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.getParcelableExtraCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.self.none.BecomeASustainerFragment
import org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.concurrent.TimeUnit
/**
* Entry-path coverage for the "become a subscriber" upsell.
*
* The upsell surface is [BecomeASustainerFragment], reached from the profile screen's badges row. The
* handoff test asserts the sheet's button launches the recurring-donation checkout route; the gating
* tests assert the "user might be a sustainer" rule in [org.thoughtcrime.securesms.profiles.manage.EditProfileFragment]:
* a non-donor sees the upsell, an existing donor is routed to badge management instead.
*/
@RunWith(AndroidJUnit4::class)
class BecomeASustainerUpsellTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
setSelfBadges(emptyList())
}
@Test
fun nonDonor_badgeTap_showsUpsellSheet() {
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
val sheet = awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
assertThat(sheet).isNotNull()
}
}
@Test
fun becomeASustainer_launchesRecurringCheckoutRoute() {
LaunchedActivityRecorder(AppSettingsActivity::class.java).use { recorder ->
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
awaitNavHostFragment(scenario) { it.filterIsInstance<BecomeASustainerFragment>().firstOrNull() }
val becomeASustainer = withText(R.string.BecomeASustainerMegaphone__become_a_sustainer)
onView(withId(R.id.recycler)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(hasDescendant(becomeASustainer)))
onView(becomeASustainer).perform(click())
val intent = recorder.awaitLaunch()
val route = intent.extras!!.keySet().firstNotNullOfOrNull { intent.getParcelableExtraCompat(it, AppSettingsRoute::class.java) }
assertThat(route).isEqualTo(AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
}
}
}
@Test
fun donor_badgeTap_opensBadgeManagement() {
setSelfBadges(listOf(donorBadge()))
ActivityScenario.launch(EditProfileActivity::class.java).use { scenario ->
onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click())
val badgeManagement = awaitNavHostFragment(scenario) { it.filterIsInstance<BadgesOverviewFragment>().firstOrNull() }
assertThat(badgeManagement).isNotNull()
val upsell = navHostFragments(scenario).filterIsInstance<BecomeASustainerFragment>().firstOrNull()
assertThat(upsell).isNull()
}
}
private fun awaitNavHostFragment(scenario: ActivityScenario<EditProfileActivity>, selector: (List<Fragment>) -> Fragment?): Fragment {
return await(description = "nav host fragment") { selector(navHostFragments(scenario)) }
}
private fun navHostFragments(scenario: ActivityScenario<EditProfileActivity>): List<Fragment> {
var fragments: List<Fragment> = emptyList()
scenario.onActivity { activity ->
val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
fragments = navHost?.childFragmentManager?.fragments ?: emptyList()
}
return fragments
}
private fun setSelfBadges(badges: List<Badge>) {
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
Recipient.self().fresh()
}
private fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
private fun <T> await(timeoutMs: Long = 10_000, pollMs: Long = 50, description: String, supplier: () -> T?): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
supplier()?.let { return it }
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
/**
* Captures the first launched activity of [activityClass] and finishes it immediately so its own
* downstream launches (e.g. the auto-launched checkout) don't cascade during the test.
*/
private inner class LaunchedActivityRecorder(private val activityClass: Class<out Activity>) : AutoCloseable {
@Volatile
private var launched: Intent? = null
private val app = context.applicationContext as Application
private val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activityClass.isInstance(activity) && launched == null) {
launched = activity.intent
activity.finish()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
init {
app.registerActivityLifecycleCallbacks(callbacks)
}
fun awaitLaunch(): Intent = await(description = "${activityClass.simpleName} launch") { launched }
override fun close() = app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
@@ -0,0 +1,90 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.unmockkObject
import org.hamcrest.Matchers.allOf
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.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class CheckoutFlowActivityTest__OneTimeDonations {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.espresso.matcher.ViewMatchers.isSelected
@@ -11,28 +13,33 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.unmockkObject
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.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.GooglePayTestRule
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.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.failure
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -46,19 +53,37 @@ class CheckoutFlowActivityTest__RecurringDonations {
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.RECURRING_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
scrollToDescendant(R.id.recycler, withId(R.id.monthly), rxRule.defaultTestScheduler)
onView(withId(R.id.monthly)).check(matches(isSelected()))
}
@Test
fun givenNoCurrentDonation_whenILoadScreen_thenIExpectContinueButton() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText("Continue")).check(matches(isDisplayed()))
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).check(matches(isDisplayed()))
}
@Test
@@ -69,8 +94,9 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
}
@@ -82,7 +108,7 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__cancel_subscription)).perform(ViewActions.click())
onView(withText(R.string.SubscribeFragment__confirm_cancellation)).check(matches(isDisplayed()))
@@ -97,50 +123,65 @@ class CheckoutFlowActivityTest__RecurringDonations {
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler)
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed()))
onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled()))
}
@Test
fun givenSubscriberPermitAcquisitionFails_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun givenSubscriptionPaymentMethodPermitRejected_whenISubscribe_thenIExpectPaymentSetupErrorDialog() {
succeedDonationPermitAcquisition()
MockEndpoints.responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") }) {
failure(402)
}
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment)
onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed()))
}
private fun initialiseActiveSubscription() {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
currency = currency,
type = InAppPaymentSubscriberRecord.Type.DONATION,
requiresCancel = false,
paymentMethodType = InAppPaymentData.PaymentMethodType.CARD,
iapSubscriptionId = null
)
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
true,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"active",
"STRIPE",
"CARD",
false
),
null
)
)
every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit)
val subscriber = registerSubscriber()
val serialized = subscriber.subscriberId.serialize()
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
ok(DonationResponses.activeSubscription(status = "active", active = true))
}
}
private fun initialisePendingSubscription() {
val subscriber = registerSubscriber()
val serialized = subscriber.subscriberId.serialize()
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains(serialized) }) {
ok(DonationResponses.activeSubscription(status = "incomplete", active = false))
}
}
private fun registerSubscriber(): InAppPaymentSubscriberRecord {
val currency = Currency.getInstance("USD")
val subscriber = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
@@ -154,25 +195,6 @@ class CheckoutFlowActivityTest__RecurringDonations {
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"incomplete",
"STRIPE",
"CARD",
false
),
null
)
)
}
return subscriber
}
}
@@ -0,0 +1,99 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isNotEmpty
import io.mockk.unmockkObject
import org.hamcrest.Matchers.allOf
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.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Milestone integration test: drives a Google Pay one-time donation through the entire lifecycle to
* receipt redemption, exercising all three outer boundaries at once — the Signal-service websocket
* responder (boost intent + receipt credential + redeem), the Stripe HTTP interceptor (payment
* method + confirm + intent status), and the test zk server (real client-side credential validation).
*/
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class CheckoutFlowActivityTest__Redemption {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
private val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, InAppPaymentType.ONE_TIME_DONATION)
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
startJobLoopForTests()
succeedDonationPermitAcquisition()
MockEndpoints.responder.registerStripeHappyPath()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenAllEndpointsSucceed_whenIDonateOnceWithGooglePay_thenIExpectAReceipt() {
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
rxRule.defaultTestScheduler.flushUntil {
SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION).isNotEmpty()
}
assertThat(SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)).isNotEmpty()
}
}
@@ -0,0 +1,280 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Bundle
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.NavHostFragment
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.hamcrest.Matchers.allOf
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Drives [CheckoutFlowActivity] through the UI "as a user" for a [DonationsCheckoutTestSpec]: selects
* an amount/tier, continues to the gateway selector, and completes the chosen payment method. Each
* payment method is a strategy dispatched on [DonationsCheckoutTestSpec.paymentMethod].
*
* Webview approval steps (PayPal, Stripe 3DS) can't complete against a mocked network, so instead of
* loading the approval URL we inject the fragment result the webview would have posted — the same
* mechanism the production dialog uses on return.
*/
class CheckoutFlowDriver(
private val composeRule: ComposeTestRule,
private val scheduler: TestScheduler
) {
companion object {
private const val TEST_NAME = "Test McTesterson"
private const val TEST_EMAIL = "test@signal.org"
/** A structurally valid German IBAN (passes mod-97), for the SEPA form. */
private const val TEST_IBAN = "DE89370400440532013000"
}
fun drive(scenario: ActivityScenario<CheckoutFlowActivity>, spec: DonationsCheckoutTestSpec) {
scheduler.triggerActions()
selectAmountOrTier(spec.inAppPaymentType)
proceedToGateway()
when (spec.paymentMethod) {
TestPaymentMethod.GOOGLE_PAY -> scenario.selectGooglePay(composeRule, scheduler, spec.inAppPaymentType)
TestPaymentMethod.CREDIT_CARD -> completeCreditCard()
TestPaymentMethod.PAYPAL -> completePayPal(scenario, spec.inAppPaymentType)
TestPaymentMethod.SEPA_DEBIT -> completeSepa()
TestPaymentMethod.IDEAL -> completeIdeal(spec.inAppPaymentType)
}
if (spec.world.stripe == StripeOutcome.REQUIRES_3DS && spec.paymentMethod.usesStripe()) {
complete3ds(scenario, spec.inAppPaymentType)
}
}
private fun selectAmountOrTier(type: InAppPaymentType) {
if (type == InAppPaymentType.ONE_TIME_DONATION) {
scrollToDescendant(R.id.recycler, withId(R.id.boost_1), scheduler)
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
scheduler.triggerActions()
}
// Recurring: the monthly tier is selected by default, so nothing to pick here.
}
private fun proceedToGateway() {
scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), scheduler)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
scheduler.triggerActions()
}
/**
* Fills the [org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment]
* with a valid test card and continues, driving the flow into the Stripe payment pipeline.
*/
private fun completeCreditCard() {
clickGatewayButton(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)
scheduler.flushUntil {
onView(withId(R.id.card_number)).check(matches(isDisplayed()))
true
}
onView(withId(R.id.card_number)).perform(ViewActions.typeText("4242424242424242"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.card_expiry)).perform(ViewActions.typeText("1228"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.card_cvv)).perform(ViewActions.typeText("123"), ViewActions.closeSoftKeyboard())
scheduler.flushUntil {
onView(withId(R.id.continue_button)).check(matches(isEnabled()))
true
}
onView(withId(R.id.continue_button)).perform(ViewActions.click())
scheduler.triggerActions()
}
/**
* Selects PayPal, then injects the approval result the webview would have returned. One-time uses a
* [PayPalConfirmationResult]; recurring posts `true`.
*/
private fun completePayPal(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
clickGatewayButton(GatewaySelectorTestTags.PAYPAL_BUTTON)
val result: Any = if (type == InAppPaymentType.ONE_TIME_DONATION) {
PayPalConfirmationResult(payerId = "test-payer", paymentId = null, paymentToken = "test-token")
} else {
true
}
awaitDialogAndSetResult(
scenario = scenario,
dialogClass = PayPalConfirmationDialogFragment::class.java,
resultKey = PayPalConfirmationDialogFragment.REQUEST_KEY,
bundle = bundleOf(PayPalConfirmationDialogFragment.REQUEST_KEY to result)
)
}
/** Injects the 3DS return the Stripe webview would have posted, resuming the Stripe pipeline. */
private fun complete3ds(scenario: ActivityScenario<CheckoutFlowActivity>, type: InAppPaymentType) {
val accessor = if (type == InAppPaymentType.ONE_TIME_DONATION) {
StripeIntentAccessor(StripeIntentAccessor.ObjectType.PAYMENT_INTENT, StripeResponses.DEFAULT_PAYMENT_INTENT_ID, "${StripeResponses.DEFAULT_PAYMENT_INTENT_ID}_secret_test")
} else {
StripeIntentAccessor(StripeIntentAccessor.ObjectType.SETUP_INTENT, StripeResponses.DEFAULT_SETUP_INTENT_ID, "${StripeResponses.DEFAULT_SETUP_INTENT_ID}_secret_test")
}
awaitDialogAndSetResult(
scenario = scenario,
dialogClass = Stripe3DSDialogFragment::class.java,
resultKey = Stripe3DSDialogFragment.REQUEST_KEY,
bundle = bundleOf(Stripe3DSDialogFragment.REQUEST_KEY to accessor)
)
}
/**
* Selects SEPA, accepts the mandate (its button scrolls the text to the bottom before it agrees, so
* it is clicked until the details form appears), fills the bank details, and donates. SEPA requires
* a EUR donation currency (see [WorldState.currencyCode]).
*/
private fun completeSepa() {
clickGatewayButton(GatewaySelectorTestTags.SEPA_BUTTON)
scheduler.flushUntil {
val ibanPresent = runCatching {
composeRule.onAllNodesWithTag(DonationTransferTestTags.SEPA_IBAN_FIELD).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
if (!ibanPresent) {
runCatching { composeRule.onNodeWithTag(DonationTransferTestTags.SEPA_MANDATE_CONTINUE_BUTTON).performClick() }
}
ibanPresent
}
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_IBAN_FIELD, TEST_IBAN)
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_NAME_FIELD, TEST_NAME)
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_EMAIL_FIELD, TEST_EMAIL)
clickWhenReady(DonationTransferTestTags.SEPA_DONATE_BUTTON)
}
/**
* Selects iDEAL, fills the bank details (email is recurring-only), and donates. iDEAL requires a EUR
* donation currency and, on success, lands in an awaiting-authorization state.
*/
private fun completeIdeal(type: InAppPaymentType) {
clickGatewayButton(GatewaySelectorTestTags.IDEAL_BUTTON)
scheduler.flushUntil {
runCatching {
composeRule.onAllNodesWithTag(DonationTransferTestTags.IDEAL_NAME_FIELD).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
}
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_NAME_FIELD, TEST_NAME)
if (type.recurring) {
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_EMAIL_FIELD, TEST_EMAIL)
}
clickWhenReady(DonationTransferTestTags.IDEAL_DONATE_BUTTON)
}
/**
* Scrolls the [listTag] lazy list until the field tagged [fieldTag] is composed and on-screen, then types
* [text] into it. The transfer forms lay their fields out in a [androidx.compose.foundation.lazy.LazyColumn],
* so a lower field is not composed on a short screen until scrolled to — without this, the input silently
* matches nothing on Firebase's smaller devices.
*/
private fun typeIntoField(listTag: String, fieldTag: String, text: String) {
composeRule.onNodeWithTag(listTag).performScrollToNode(hasTestTag(fieldTag))
composeRule.onNodeWithTag(fieldTag).performTextInput(text)
}
/** Retries clicking a Compose node until the click lands (e.g. once a submit button becomes enabled). */
private fun clickWhenReady(tag: String) {
scheduler.flushUntil {
runCatching {
composeRule.onNodeWithTag(tag).performClick()
true
}.getOrDefault(false)
}
scheduler.triggerActions()
}
/**
* Clicks a Compose gateway-selector button and waits for the bottom sheet to dismiss. For methods
* other than Google Pay the checkout then navigates to the method's fragment; Google Pay instead
* requires a result injection (see `selectGooglePay`).
*/
private fun clickGatewayButton(tag: String) {
scheduler.flushUntil {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
// The selector is a vertically scrolling bottom sheet; on a short screen the lower gateway buttons are
// off-screen, so bring the target on-screen before clicking (mirrors GatewaySelectorBottomSheetContentTest).
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(tag))
composeRule.onNodeWithTag(tag).performClick()
scheduler.flushUntil {
runCatching {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()
}.getOrDefault(true)
}
}
/**
* Waits for a navigated dialog of [dialogClass] to be present, then posts [bundle] as its fragment
* result on the nav host's fragment manager (where the in-progress fragment registered its listener).
*/
private fun <T : Fragment> awaitDialogAndSetResult(
scenario: ActivityScenario<CheckoutFlowActivity>,
dialogClass: Class<T>,
resultKey: String,
bundle: Bundle
) {
scheduler.flushUntil {
var delivered = false
scenario.onActivity { activity ->
val fragmentManager = navHostChildFragmentManager(activity)
if (fragmentManager != null && fragmentManager.fragments.any { dialogClass.isInstance(it) }) {
fragmentManager.setFragmentResult(resultKey, bundle)
delivered = true
}
}
delivered
}
scheduler.triggerActions()
}
private fun navHostChildFragmentManager(activity: FragmentActivity): FragmentManager? {
return activity.supportFragmentManager.fragments
.filterIsInstance<NavHostFragment>()
.firstOrNull()
?.childFragmentManager
}
private fun TestPaymentMethod.usesStripe(): Boolean {
return this != TestPaymentMethod.PAYPAL
}
}
@@ -0,0 +1,142 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.app.Activity
import android.content.Intent
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import io.mockk.every
import io.mockk.mockkObject
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.rest.RestStatusCodeError
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Forces donation-permit acquisition to fail, so the checkout surfaces a payment-setup error before
* reaching the payment method. Stubs the [DonationPermits] object directly (the underlying
* `createDonationPermits` call rides the websocket, which the responder does not mint valid permits
* for). Call `unmockkObject(DonationPermits)` in teardown.
*/
fun failDonationPermitAcquisition(statusCode: Int = 500) {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
}
/**
* Makes donation-permit acquisition succeed with a placeholder permit, letting the checkout proceed
* past the permit gate to the payment method. Call `unmockkObject(DonationPermits)` in teardown.
*/
fun succeedDonationPermitAcquisition() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
}
/**
* The instrumentation app stubs [org.thoughtcrime.securesms.ApplicationContext.beginJobLoop] to a no-op, so enqueued
* jobs never run. The checkout pipeline drives a real setup job through the JobManager, so the loop must be started.
*
* Before starting it, cancel any leftover in-app-payment jobs. A recurring setup job retries indefinitely over a
* one-day lifespan, so a spec that fails setup leaves one persisted in the JobManager. On Firebase Test Lab the
* orchestrator's `clearPackageData` does not reliably wipe the JobManager between parameterized specs (it does on a
* local emulator, which is why this only surfaces on-device), so that job survives into the next spec and runs after
* its InAppPayment row was deleted in setup — `InAppPaymentsRepository.resolveLock` then throws "Not found" from the
* job thread and crashes the whole app. Clearing the queues here makes each test start from a clean job state.
*/
fun startJobLoopForTests() {
AppDependencies.jobManager.cancelAllInQueues(
setOf(
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_DONATION),
InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP)
)
)
AppDependencies.jobManager.flush()
(AppDependencies.application as SignalInstrumentationApplicationContext).beginJobLoopForTests()
}
/**
* Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the
* checkout, navigating to the payment-in-progress screen where the pipeline runs.
*
* The gateway selector is populated by Rx work on [scheduler], so we [flushUntil] the button is present
* rather than sleeping. Selecting Google Pay dismisses the gateway sheet and hands a fragment result back to
* the checkout, which runs `launchGooglePay` -> `provideGatewayRequestForGooglePay` on [scheduler]; only then
* does the checkout's subscriber consume a [GooglePayComponent.googlePayResultPublisher] emission
* (`consumeGatewayRequestForGooglePay` returns null until then, and the publisher is a hot PublishSubject that
* drops earlier emissions). So we [flushUntil] the sheet has dismissed — a real signal that the click was
* fully processed — before dispatching the result, rather than pumping a fixed number of times and racing.
*/
fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
composeRule: ComposeTestRule,
scheduler: TestScheduler,
inAppPaymentType: InAppPaymentType
) {
scheduler.flushUntil {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON))
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
scheduler.flushUntil {
// Once the gateway sheet dismisses there is no Compose hierarchy left, so fetchSemanticsNodes throws
// rather than returning empty; treat both the empty list and the missing hierarchy as "sheet gone".
runCatching {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isEmpty()
}.getOrDefault(true)
}
onActivity { activity ->
(activity as GooglePayComponent).googlePayResultPublisher.onNext(
GooglePayComponent.GooglePayResult(
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPaymentType),
resultCode = Activity.RESULT_OK,
data = Intent()
)
)
}
}
/**
* Waits for a dialog whose title is [titleResId] to be displayed. Matches any dialog by title (error,
* confirmation, thanks, etc.) — it is not specific to error dialogs.
*
* [flushUntil] advances the Rx pipeline (which may create the payment, enqueue the real setup job, and react
* to its committed state) while yielding real time for any job to run, until the dialog renders. A single
* condition-driven pump rather than a fixed-duration wall-clock poll. Letting the Espresso check throw (rather
* than collapsing it to a boolean) lets [flushUntil] chain the last matcher failure as the timeout cause.
*/
fun awaitDialog(
scheduler: TestScheduler,
titleResId: Int,
timeoutMillis: Long = 15_000
) {
scheduler.flushUntil(timeoutMillis) {
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
true
}
}
@@ -0,0 +1,282 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isNotEmpty
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.testing.GooglePayTestRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RawFlag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.flushUntil
/**
* Spec-driven integration test for donation checkout. Each [DonationsCheckoutTestSpec] declares a
* "world" (what each endpoint returns) and an expected outcome; this runner applies the world, drives
* [CheckoutFlowActivity] through the real UI via [CheckoutFlowDriver], and asserts the outcome.
*
* Adding coverage is data: append a spec to [DonationsCheckoutSpecs.ALL].
*/
@RunWith(Parameterized::class)
@RemoteConfigForTest(
rawFlags = [
RawFlag("android.sepa.debit.donations.5", "true"),
RawFlag("android.ideal.donations.5", "true"),
// Region codes are matched against the digit-only self E164 ("15555550101"), so no leading "+".
RawFlag("global.donations.sepaEnabledRegions", "1"),
RawFlag("global.donations.idealEnabledRegions", "1")
]
)
class DonationsCheckoutSpecTest(private val spec: DonationsCheckoutTestSpec) {
@get:Rule
val harness = SignalActivityRule(othersCount = 10)
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val rxRule = RxTestSchedulerRule()
@get:Rule
val googlePayRule = GooglePayTestRule()
@get:Rule
val composeRule = createEmptyComposeRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
startJobLoopForTests()
spec.world.apply()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun runSpec() {
val intent = CheckoutFlowActivity.createIntent(InstrumentationRegistry.getInstrumentation().targetContext, spec.inAppPaymentType)
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
CheckoutFlowDriver(composeRule, rxRule.defaultTestScheduler).drive(scenario, spec)
when (val expected = spec.expected) {
is ExpectedOutcome.Redeemed -> {
rxRule.defaultTestScheduler.flushUntil { receipts().isNotEmpty() }
assertThat(receipts()).isNotEmpty()
}
is ExpectedOutcome.ErrorDialog -> {
awaitDialog(rxRule.defaultTestScheduler, expected.titleRes)
if (expected.messageRes != null) {
onView(withText(expected.messageRes)).inRoot(isDialog()).check(matches(isDisplayed()))
}
}
is ExpectedOutcome.PaymentSubmitted -> {
rxRule.defaultTestScheduler.flushUntil { submittedPayment() != null }
val payment = submittedPayment()
assertThat(payment).isNotNull()
assertThat(payment!!.data.error).isNull()
}
}
}
/** The latest payment once it has moved past CREATED with no error, or null if not yet there. */
private fun submittedPayment(): InAppPaymentTable.InAppPayment? {
return SignalDatabase.inAppPayments.getLatestInAppPaymentByType(spec.inAppPaymentType)
?.takeIf { it.state != InAppPaymentTable.State.CREATED && it.data.error == null }
}
private fun receipts(): List<InAppPaymentReceiptRecord> {
val type = if (spec.inAppPaymentType == InAppPaymentType.ONE_TIME_DONATION) {
InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION
} else {
InAppPaymentReceiptRecord.Type.RECURRING_DONATION
}
return SignalDatabase.donationReceipts.getReceipts(type)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun specs(): Collection<DonationsCheckoutTestSpec> = DonationsCheckoutSpecs.ALL
}
}
/**
* The enumerated donation checkout scenarios. Currently seeded with Google Pay coverage; the other
* payment-method drivers (card, SEPA, iDEAL, PayPal) and their scenarios are added alongside their
* [CheckoutFlowDriver] strategies.
*/
object DonationsCheckoutSpecs {
private val paymentErrorDialog = ExpectedOutcome.ErrorDialog(
titleRes = R.string.DonationsErrors__error_processing_payment,
messageRes = R.string.DonationsErrors__your_payment
)
val ALL: List<DonationsCheckoutTestSpec> = listOf(
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_oneTime_signalCreateIntent402_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(signalCreatePaymentStatus = 402),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_recurring_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_3dsApproved_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.REQUIRES_3DS),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "payPal_oneTime_happyPath_isRedeemed",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.PAYPAL,
world = WorldState(),
expected = ExpectedOutcome.Redeemed
),
DonationsCheckoutTestSpec(
name = "sepa_oneTime_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "ideal_oneTime_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.IDEAL,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
// Recurring happy paths. Asserted as "submitted": the recurring-specific work is the setup
// pipeline (create subscriber, setup intent, set default, set level); receipt redemption is
// shared with the one-time path already covered above.
DonationsCheckoutTestSpec(
name = "googlePay_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "creditCard_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "payPal_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.PAYPAL,
world = WorldState(recurringActivatesAfterSetup = true),
expected = ExpectedOutcome.PaymentSubmitted
),
DonationsCheckoutTestSpec(
name = "sepa_recurring_happyPath_isSubmitted",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.SEPA_DEBIT,
world = WorldState(currencyCode = "EUR"),
expected = ExpectedOutcome.PaymentSubmitted
),
// Additional error cases.
DonationsCheckoutTestSpec(
name = "creditCard_oneTime_permitFailure_showsError",
inAppPaymentType = InAppPaymentType.ONE_TIME_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(permit = PermitOutcome.FAILURE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "creditCard_recurring_stripeDecline_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.CREDIT_CARD,
world = WorldState(stripe = StripeOutcome.DECLINE),
expected = paymentErrorDialog
),
DonationsCheckoutTestSpec(
name = "googlePay_recurring_signalCreatePaymentMethod402_showsError",
inAppPaymentType = InAppPaymentType.RECURRING_DONATION,
paymentMethod = TestPaymentMethod.GOOGLE_PAY,
world = WorldState(signalCreatePaymentStatus = 402),
expected = paymentErrorDialog
)
)
}
@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.annotation.StringRes
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
import org.thoughtcrime.securesms.testing.endpoints.failure
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.thoughtcrime.securesms.testing.endpoints.registerPayPalHappyPath
import org.thoughtcrime.securesms.testing.endpoints.registerStripeHappyPath
import java.util.Currency
import java.util.concurrent.atomic.AtomicBoolean
/**
* A declarative "state of the world" + expected outcome for one donation checkout scenario. Specs
* are enumerated by [DonationsCheckoutSpecTest] and each one drives [CheckoutFlowActivity] end to end
* for its [paymentMethod].
*/
data class DonationsCheckoutTestSpec(
val name: String,
val inAppPaymentType: InAppPaymentType,
val paymentMethod: TestPaymentMethod,
val world: WorldState = WorldState(),
val expected: ExpectedOutcome
) {
/** Used as the parameterized test label. */
override fun toString(): String = name
}
enum class TestPaymentMethod {
GOOGLE_PAY,
CREDIT_CARD,
SEPA_DEBIT,
IDEAL,
PAYPAL
}
/** Whether donation-permit acquisition succeeds or fails. */
enum class PermitOutcome { SUCCESS, FAILURE }
/** The Stripe HTTP edge's behavior for a scenario. */
enum class StripeOutcome {
SUCCESS,
DECLINE,
FAILURE,
REQUIRES_3DS
}
/** Pre-existing recurring subscription state (recurring scenarios only). */
enum class SubscriptionState { NONE, ACTIVE, PENDING }
/**
* The knobs that define what each endpoint returns. [apply] registers the corresponding handlers on
* the shared responder (overriding the [org.thoughtcrime.securesms.testing.InAppPaymentsRule]
* defaults, since later registrations win) and sets the permit outcome.
*/
data class WorldState(
val permit: PermitOutcome = PermitOutcome.SUCCESS,
val stripe: StripeOutcome = StripeOutcome.SUCCESS,
val declineCode: String = "card_declined",
val signalCreatePaymentStatus: Int = 200,
val receiptCredentialStatus: Int = 200,
val redeemStatus: Int = 200,
val existingSubscription: SubscriptionState = SubscriptionState.NONE,
/** Donation currency to use. SEPA/iDEAL require EUR. */
val currencyCode: String = "USD",
/**
* For a new recurring donation: report no active subscription until the subscription level is set
* during setup, then report an active one. This lets the checkout show "Continue" (new subscriber)
* while still letting the recurring receipt-credential context job see an active subscription to redeem.
*/
val recurringActivatesAfterSetup: Boolean = false
) {
fun apply() {
val currency = Currency.getInstance(currencyCode)
SignalStore.inAppPayments.setOneTimeCurrency(currency)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
when (permit) {
PermitOutcome.SUCCESS -> succeedDonationPermitAcquisition()
PermitOutcome.FAILURE -> failDonationPermitAcquisition()
}
val responder = MockEndpoints.responder
// Signal-service PayPal endpoints (harmless for non-PayPal specs; distinct paths).
responder.registerPayPalHappyPath()
// Stripe HTTP edge.
responder.registerStripeHappyPath()
when (stripe) {
StripeOutcome.SUCCESS -> Unit
StripeOutcome.DECLINE -> responder.register({ it.method == "POST" && (it.path.endsWith("/confirm") || it.path.contains("/payment_methods")) }) {
failure(402, StripeResponses.declineError(declineCode))
}
StripeOutcome.FAILURE -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
failure(402, StripeResponses.failureError("card_not_supported"))
}
StripeOutcome.REQUIRES_3DS -> responder.register({ it.method == "POST" && it.path.endsWith("/confirm") }) {
ok(StripeResponses.confirm3dsRequired())
}
}
// Signal-service edge overrides.
if (signalCreatePaymentStatus != 200) {
responder.register({ it.method == "POST" && (it.path.contains("/create_payment_method") || it.path.endsWith("/boost/create")) }) {
failure(signalCreatePaymentStatus)
}
}
if (receiptCredentialStatus != 200) {
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { failure(receiptCredentialStatus) }
}
if (redeemStatus != 200) {
responder.register({ it.method == "POST" && it.path.contains("/redeem-receipt") }) { failure(redeemStatus) }
}
when (existingSubscription) {
SubscriptionState.NONE -> Unit
SubscriptionState.ACTIVE -> registerSubscription(status = "active", active = true)
SubscriptionState.PENDING -> registerSubscription(status = "incomplete", active = false)
}
if (recurringActivatesAfterSetup) {
val activated = AtomicBoolean(false)
responder.register({ it.method == "PUT" && it.path.contains("/level/") }) {
activated.set(true)
ok()
}
responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") && !it.path.contains("bank_mandate") }) {
if (activated.get()) ok(DonationResponses.activeSubscription()) else ok(DonationResponses.emptySubscription())
}
}
}
private fun registerSubscription(status: String, active: Boolean) {
MockEndpoints.responder.register({ it.method == "GET" && it.path.contains("/v1/subscription/") && !it.path.contains("configuration") }) {
EndpointResponse(status = 200, body = DonationResponses.activeSubscription(status = status, active = active))
}
}
}
/** The expected terminal result of driving a spec. */
sealed interface ExpectedOutcome {
/** An error dialog with [titleRes] (and optionally [messageRes]) is shown. */
data class ErrorDialog(@StringRes val titleRes: Int, @StringRes val messageRes: Int? = null) : ExpectedOutcome
/** The full lifecycle completes and a donation receipt is recorded. */
data object Redeemed : ExpectedOutcome
/**
* Payment setup succeeded and the payment moved past [org.thoughtcrime.securesms.database.InAppPaymentTable.State.CREATED]
* with no error. Used for bank-transfer methods (SEPA/iDEAL), which don't redeem immediately —
* they land in a pending / awaiting-authorization state.
*/
data object PaymentSubmitted : ExpectedOutcome
}
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.CDN_3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -0,0 +1,200 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.fragment.app.FragmentActivity
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.getSerializableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Entry-path coverage for the inline chat donation prompt: the Release-Channel "donation request"
* conversation item that renders a "Donate" action button and opens the one-time checkout.
*
* The conversation is hosted by [MainActivity], so this drives it the same way
* [org.thoughtcrime.securesms.main.MainNavigationLaunchTest] does — a manual lifecycle-callback launch
* plus direct view interaction, since ActivityScenario/Espresso misbehave for the conversation
* custom-action intent.
*
* Unlike the megaphone and subscriber-upsell paths, this prompt is intentionally NOT gated by sustainer
* status; [sustainer_donatePromptStillShown] documents that.
*/
@RunWith(AndroidJUnit4::class)
class InlineDonationPromptTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
SignalStore.inAppPayments.setLastEndOfPeriod(0L)
SignalDatabase.recipients.setBadges(Recipient.self().id, emptyList())
Recipient.self().fresh()
}
@Test
fun nonSustainer_donatePromptOpensOneTimeCheckout() {
seedDonationRequest()
assertDonatePromptOpensCheckout()
}
@Test
fun sustainer_donatePromptStillShown() {
SignalStore.inAppPayments.setLastEndOfPeriod(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + TimeUnit.DAYS.toSeconds(30))
SignalDatabase.recipients.setBadges(Recipient.self().id, listOf(donorBadge()))
Recipient.self().fresh()
seedDonationRequest()
assertDonatePromptOpensCheckout()
}
private fun assertDonatePromptOpensCheckout() {
val activity = launchConversation()
val donateButton = awaitView(activity, R.id.conversation_update_action)
runOnMainSync { donateButton.performClick() }
val dialog = await(description = "one-time checkout dialog") {
activity.supportFragmentManager.findFragmentByTag(ONE_TIME_NAV_TAG)
}
assertThat(dialog).isInstanceOf(DonateToSignalFragment.Dialog::class)
val type = dialog.requireArguments().getSerializableCompat(DonateToSignalFragment.Dialog.ARG, InAppPaymentType::class.java)
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
}
private fun seedDonationRequest() {
val recipientId: RecipientId = SignalDatabase.recipients.insertReleaseChannelRecipient()
SignalStore.releaseChannel.setReleaseChannelRecipientId(recipientId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
SignalDatabase.messages.insertBoostRequestMessage(recipientId, threadId)
}
private fun launchConversation(): MainActivity {
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val conversationIntent = ConversationIntents.createBuilder(context, recipientId, threadId).blockingGet().build()
val launchIntent = Intent(context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val app = context.applicationContext as Application
val resumed = CountDownLatch(1)
val holder = arrayOfNulls<MainActivity>(1)
val callbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
// Capture on resume rather than create: a CLEAR_TOP|SINGLE_TOP conversation intent may be
// delivered to an existing MainActivity via onNewIntent, which never fires onActivityCreated.
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) {
holder[0] = activity
resumed.countDown()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
app.registerActivityLifecycleCallbacks(callbacks)
app.startActivity(launchIntent)
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(callbacks)
error("MainActivity did not reach RESUMED within 15s")
}
app.unregisterActivityLifecycleCallbacks(callbacks)
return holder[0] ?: error("MainActivity was not captured")
}
private fun awaitView(activity: FragmentActivity, viewId: Int): View {
return await(description = "view $viewId") {
activity.findViewById<View>(viewId)?.takeIf { it.isShown }
}
}
private fun <T> await(
timeoutMs: Long = 15_000,
pollMs: Long = 50,
description: String,
supplier: () -> T?
): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val result = runOnMainSync(supplier)
if (result != null) return result
Thread.sleep(pollMs)
}
error("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun <T> runOnMainSync(block: () -> T): T {
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync produced no result"))
InstrumentationRegistry.getInstrumentation().runOnMainSync { result = runCatching(block) }
return result.getOrThrow()
}
private fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
companion object {
private const val ONE_TIME_NAV_TAG = "one_time_nav"
}
}
@@ -0,0 +1,393 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isGreaterThanOrEqualTo
import assertk.assertions.isLessThan
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end UI test of the unread divider. Seeds a thread with many unread messages and opens it via the notification
* path (which enters the conversation with no explicit jump point — functionally "open a chat with X unread"), then
* verifies the real pipeline (repository -> view model -> fragment -> decoration) anchors the divider to the oldest
* unread message and scrolls there rather than opening at the bottom.
*
* The launch harness mirrors [org.thoughtcrime.securesms.main.MainNavigationLaunchTest]: ActivityScenario can't track
* MainActivity launched with a custom-action intent, so we start it via Application#startActivity and observe lifecycle
* callbacks instead.
*/
@RunWith(AndroidJUnit4::class)
class UnreadDividerInstrumentationTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
@Test
fun opensScrolledToOldestUnreadWithCorrectDividerState() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val totalUnread = 50
val oldestSentTime = 1000L
var oldestUnreadId = -1L
for (i in 0 until totalUnread) {
val id = insertIncoming(threadId, recipientId, time = oldestSentTime + i, body = "unread $i")
if (i == 0) {
oldestUnreadId = id
}
}
// Derive expectations from the DB the same way the app does, so the test is robust to any extra system rows.
val expectedUnreadCount = SignalDatabase.messages.getUnreadCount(threadId)
val firstUnreadPosition = SignalDatabase.messages.getMessagePositionByDateReceivedTimestamp(threadId, oldestSentTime, false)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "conversation scrolled to oldest unread") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
val state = decoration.unreadStateForTesting as? ConversationItemDecorations.UnreadState.CompleteUnreadState ?: return@await null
val view = recycler.layoutManager?.findViewByPosition(firstUnreadPosition) ?: return@await null
Observed(state.unreadCount, state.firstUnreadId, view.top, recycler.height)
}
assertThat(result.unreadCount).isEqualTo(expectedUnreadCount)
assertThat(result.firstUnreadId).isEqualTo(oldestUnreadId)
// The oldest unread is laid out in the top half -> we scrolled up to it instead of opening at the bottom (where,
// with this many messages, it would be off-screen above and findViewByPosition would have returned null).
assertThat(result.firstUnreadTop).isGreaterThanOrEqualTo(0)
assertThat(result.firstUnreadTop).isLessThan(result.recyclerHeight / 2)
}
}
@Test
fun fullyReadConversationOpensAtBottomWithoutDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "read $i")
}
SignalDatabase.threads.setRead(threadId)
// Precondition: nothing is unread, so there should be no divider.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isEqualTo(0)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "fully-read conversation opened at the bottom") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// The newest message is position 0; if it's laid out, the list loaded and settled at the bottom.
val newest = recycler.layoutManager?.findViewByPosition(0) ?: return@await null
BottomObserved(decoration.unreadStateForTesting, newest.bottom, recycler.height)
}
assertThat(result.unreadState).isEqualTo(ConversationItemDecorations.UnreadState.None)
// Newest message sits in the lower half -> opened at the bottom (with this many messages it would be off-screen
// below if we'd opened at the top).
assertThat(result.newestBottom).isGreaterThan(result.recyclerHeight / 2)
}
}
@Test
fun outgoingMessageNewerThanUnreadClearsDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
// A few unread incoming messages, then a newer outgoing reply. Kept small so all rows load in the initial page.
insertIncoming(threadId, recipientId, time = 1000L, body = "unread 0")
insertIncoming(threadId, recipientId, time = 1001L, body = "unread 1")
insertIncoming(threadId, recipientId, time = 1002L, body = "unread 2")
val outgoing = OutgoingMessage.text(
threadRecipient = Recipient.resolved(recipientId),
body = "my reply",
expiresIn = 0,
sentTimeMillis = 1003L
)
SignalDatabase.messages.insertMessageOutbox(outgoing, threadId)
// Precondition: the messages are still unread at the DB level, so the divider would show if it weren't for the
// newer outgoing message clearing it.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
launch(recipientId).use { launched ->
val cleared = await(timeoutMs = 20_000, description = "divider cleared by newer outgoing message") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// Wait until the list has loaded (outgoing at position 0 laid out) before reading the resolved state.
recycler.layoutManager?.findViewByPosition(0) ?: return@await null
if (decoration.unreadStateForTesting == ConversationItemDecorations.UnreadState.None) true else null
}
assertThat(cleared).isEqualTo(true)
}
}
@Test
fun scrollingToBottomMarksEverythingReadAndDrainsUnreadCount() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
// getUnreadCount is the shared source for the chat-list badge and the scroll-to-bottom button's count, so
// asserting on it verifies the number the user sees updating as they scroll.
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
// Jump to the newest message; revealing it marks every earlier message read (MarkReadHelper.onViewsRevealed).
runOnMain {
launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)?.scrollToPosition(0)
}
// Scrolling through the thread drains the unread count to 0.
await(timeoutMs = 20_000, description = "unread count reaches 0 after scrolling to the bottom") {
if (SignalDatabase.messages.getUnreadCount(threadId) == 0) true else null
}
}
}
@Test
fun scrollingPartwayLeavesExactlyTheUnreadMessagesBelowTheViewport() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
// The chat opens at the oldest unread (near the top); scroll down to roughly the middle.
runOnMain {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(total / 2, 0)
}
// Once mark-read settles, the unread count must equal the index of the newest visible message — i.e. exactly the
// messages still below the viewport (reverse layout: position 0 = newest, so index N = N newer messages). This is
// the number the scroll-to-bottom button and chat-list badge show; it must not over- or under-count mid-scroll.
val stableCount = awaitStableUnreadCount(threadId)
val newestVisiblePosition = await(timeoutMs = 5_000, description = "newest visible position") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.takeIf { it >= 0 }
}
assertThat(stableCount).isEqualTo(newestVisiblePosition)
// Sanity: we exercised a genuine mid-scroll point, not the very top or bottom.
assertThat(stableCount).isGreaterThan(0)
assertThat(stableCount).isLessThan(total)
}
}
/** Polls [MessageTable.getUnreadCount] until it holds steady (mark-read is debounced + async), then returns it. */
private fun awaitStableUnreadCount(threadId: Long, timeoutMs: Long = 20_000): Int {
val deadline = System.currentTimeMillis() + timeoutMs
var last = Int.MIN_VALUE
var stableSince = System.currentTimeMillis()
while (System.currentTimeMillis() < deadline) {
val current = SignalDatabase.messages.getUnreadCount(threadId)
if (current == last) {
if (System.currentTimeMillis() - stableSince >= 500) {
return current
}
} else {
last = current
stableSince = System.currentTimeMillis()
}
Thread.sleep(100)
}
throw AssertionError("Unread count never stabilized (last observed = $last)")
}
private data class BottomObserved(
val unreadState: ConversationItemDecorations.UnreadState,
val newestBottom: Int,
val recyclerHeight: Int
)
private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, body: String): Long {
val message = IncomingMessage(
type = MessageType.NORMAL,
from = from,
sentTimeMillis = time,
serverTimeMillis = time,
receivedTimeMillis = time,
body = body
)
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
}
private data class Observed(
val unreadCount: Int,
val firstUnreadId: Long,
val firstUnreadTop: Int,
val recyclerHeight: Int
)
private fun RecyclerView.conversationItemDecorations(): ConversationItemDecorations? {
for (i in 0 until itemDecorationCount) {
val decoration = getItemDecorationAt(i)
if (decoration is ConversationItemDecorations) {
return decoration
}
}
return null
}
private fun runOnMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync { block() }
}
/** Polls [block] on the main thread until it returns non-null, failing after [timeoutMs]. */
private fun <T> await(timeoutMs: Long, pollMs: Long = 100, description: String, block: () -> T?): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
var value: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync { value = block() }
if (value != null) {
return value!!
}
Thread.sleep(pollMs)
}
throw AssertionError("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun launch(recipientId: RecipientId): Launched {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val conversationFragments: MutableList<ConversationFragment> = Collections.synchronizedList(mutableListOf())
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val fragmentCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
if (f is ConversationFragment) {
conversationFragments.add(f)
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
if (f is ConversationFragment) {
conversationFragments.remove(f)
}
}
}
val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities.add(activity)
if (activity is MainActivity) {
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true)
}
}
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) {
resumed.countDown()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
allActivities.remove(activity)
}
}
app.registerActivityLifecycleCallbacks(activityCallbacks)
// Open the conversation the way a notification tap does: a conversation intent with no starting position.
val conversationIntent = ConversationIntents.createBuilder(harness.context, recipientId, -1L).blockingGet().build()
val intent = Intent(harness.context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
// Application#startActivity from a non-Activity context requires a new task.
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
app.startActivity(intent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw AssertionError("MainActivity did not reach RESUMED within 15s")
}
return Launched(conversationFragments, app, activityCallbacks, allActivities)
}
private class Launched(
private val conversationFragments: List<ConversationFragment>,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
fun latestConversationFragment(): ConversationFragment? = synchronized(conversationFragments) { conversationFragments.lastOrNull() }
override fun close() {
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
}
@@ -11,6 +11,7 @@ import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isNotEmpty
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNull
import assertk.assertions.isTrue
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
@@ -20,22 +21,22 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.copyTo
import org.signal.core.util.stream.NullOutputStream
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
@@ -67,7 +68,7 @@ class AttachmentTableTest {
@Test
fun givenABlob_whenIInsert2AttachmentsForPreUpload_thenIExpectDistinctIdsButSameFileName() {
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val highQualityProperties = createHighQualityTransformProperties()
val highQualityImage = createAttachment(1, blob, highQualityProperties)
val attachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
@@ -80,7 +81,7 @@ class AttachmentTableTest {
@FlakyTest
@Test
fun givenABlobAndDifferentTransformQuality_whenIInsert2AttachmentsForPreUpload_thenIExpectDifferentFileInfos() {
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val highQualityProperties = createHighQualityTransformProperties()
val highQualityImage = createAttachment(1, blob, highQualityProperties)
val lowQualityImage = createAttachment(1, blob, TransformProperties.empty())
@@ -107,7 +108,7 @@ class AttachmentTableTest {
@Ignore("test is flaky")
@Test
fun givenIdenticalAttachmentsInsertedForPreUpload_whenIUpdateAttachmentDataAndSpecifyOnlyModifyThisAttachment_thenIExpectDifferentFileInfos() {
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val highQualityProperties = createHighQualityTransformProperties()
val highQualityImage = createAttachment(1, blob, highQualityProperties)
val attachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
@@ -143,9 +144,9 @@ class AttachmentTableTest {
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val compressedData = byteArrayOf(1, 2, 3)
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val previousAttachment = createAttachment(1, BlobProvider.getInstance().forData(compressedData).createForSingleSessionInMemory(), TransformProperties.empty())
val previousAttachment = createAttachment(1, AppDependencies.blobs.forData(compressedData).createForSingleSessionInMemory(), TransformProperties.empty())
val previousDatabaseAttachmentId: AttachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(1, listOf(previousAttachment), emptyList()).values.first()
val standardQualityPreUpload = createAttachment(1, blobUncompressed, TransformProperties.empty())
@@ -178,7 +179,7 @@ class AttachmentTableTest {
fun doNotDedupedFileIfUsedByAnotherAttachmentWithADifferentTransformProperties() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val standardQualityPreUpload = createAttachment(1, blobUncompressed, TransformProperties.empty())
val standardDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(standardQualityPreUpload)
@@ -204,7 +205,7 @@ class AttachmentTableTest {
@Test
fun resetArchiveTransferStateByPlaintextHashAndRemoteKey_singleMatch() {
// Given an attachment with some plaintextHash+remoteKey
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val blob = AppDependencies.blobs.forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
val attachment = createAttachment(1, blob, TransformProperties.empty())
val attachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(-1L, listOf(attachment), emptyList()).values.first()
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, AttachmentTableTestUtil.createUploadResult(attachmentId))
@@ -219,6 +220,26 @@ class AttachmentTableTest {
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun resetArchiveTransferStateForLocalBackupMedia_onlyResetsLocalBackupMedia() {
// Given one archive-finished attachment restored from a local backup, and one that wasn't
val localBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = createArchivedAttachment(localBackupKey = Random.nextBytes(32)))).map { it.messageId }.get()
val localBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(localBackupMessageId).first().attachmentId
val nonLocalBackupMessageId = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 1.days, attachment = createArchivedAttachment())).map { it.messageId }.get()
val nonLocalBackupAttachmentId = SignalDatabase.attachments.getAttachmentsForMessage(nonLocalBackupMessageId).first().attachmentId
SignalDatabase.attachments.setArchiveTransferState(localBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
SignalDatabase.attachments.setArchiveTransferState(nonLocalBackupAttachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
val resetCount = SignalDatabase.attachments.resetArchiveTransferStateForLocalBackupMedia()
// Only the local-backup attachment is reset
assertThat(resetCount).isEqualTo(1)
assertThat(SignalDatabase.attachments.getAttachment(localBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(SignalDatabase.attachments.getAttachment(nonLocalBackupAttachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun given10NewerAnd10OlderAttachments_whenIGetEachBatch_thenIExpectProperBucketing() {
val now = System.currentTimeMillis().milliseconds
@@ -259,7 +280,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithAMessageThatExpiresIn5Minutes_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoNotExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty())
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment, expiresIn = 5.minutes)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -278,7 +299,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithAMessageThatExpiresIn5Days_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty())
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment, expiresIn = 5.days)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -297,7 +318,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithAMessageWithExpirationStartedThatExpiresIn5Days_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty())
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment, expiresIn = 5.days)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -317,7 +338,7 @@ class AttachmentTableTest {
fun givenAnAttachmentWithALongTextAttachment_whenIGetAttachmentsThatNeedArchiveUpload_thenIDoNotExpectThatAttachment() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val blobUncompressed = AppDependencies.blobs.forData(uncompressData).createForSingleSessionInMemory()
val attachment = createAttachment(1, blobUncompressed, TransformProperties.empty(), contentType = MediaUtil.LONG_TEXT)
val message = createIncomingMessage(serverTime = 0.days, attachment = attachment)
val messageId = SignalDatabase.messages.insertMessageInbox(message).map { it.messageId }.get()
@@ -418,6 +439,39 @@ class AttachmentTableTest {
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenLocalBackupRestore_whenIFinalizeAttachment_thenIExpectArchiveStateNoneSoItGetsUploaded() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Data is restored from a local backup file, not the archive CDN
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = false)
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(result.archiveCdn).isNull()
}
@Test
fun givenArchiveCdnRestore_whenIFinalizeAttachment_thenIExpectArchiveStateFinished() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val attachment = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Data is restored directly from the archive CDN
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data), archiveRestore = true, restoredFromArchiveCdn = true)
val result = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(result.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenAttachmentsWithMatchingMediaId_whenISetArchiveFinishedForMatchingMediaObjects_thenIExpectThoseAttachmentsToBeMarkedFinished() {
// GIVEN
@@ -585,7 +639,7 @@ class AttachmentTableTest {
).get()
}
private fun createArchivedAttachment(): Attachment {
private fun createArchivedAttachment(localBackupKey: ByteArray? = null): Attachment {
return ArchivedAttachment(
contentType = "image/jpeg",
size = 1024,
@@ -609,7 +663,7 @@ class AttachmentTableTest {
quoteTargetContentType = null,
uuid = UUID.randomUUID(),
fileName = null,
localBackupKey = null
localBackupKey = localBackupKey
)
}
@@ -5,11 +5,11 @@
package org.thoughtcrime.securesms.database
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.Cdn
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import kotlin.random.Random
@@ -12,21 +12,21 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId
import org.signal.core.models.database.AttachmentId
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.core.util.readFully
import org.signal.core.util.stream.LimitedInputStream
import org.signal.core.util.update
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.MediaStream
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
@@ -671,7 +671,7 @@ class AttachmentTableTest_deduping {
}
fun insertWithData(data: ByteArray, transformProperties: TransformProperties = TransformProperties.empty()): AttachmentId {
val uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val uri = AppDependencies.blobs.forData(data).createForSingleSessionInMemory()
val attachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
@@ -10,9 +10,14 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.DISAPPEAR_MORE, "true")])
@RunWith(AndroidJUnit4::class)
class CallTableTest {
@@ -431,7 +436,7 @@ class CallTableTest {
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(1L, call?.timestamp)
}
@@ -977,6 +982,80 @@ class CallTableTest {
// assertEquals(0, allCallEvents.size)
}
@Test
fun givenAMissedOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, harness.others[1], System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val ringerAci = Recipient.resolved(harness.others[1]).requireAci()
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, ringerAci, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST, true)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAnOutgoingOneToOneCallFromSync_whenInserted_thenTimerIsStarted() {
val callId = 1L
val peer = harness.others[0]
val timestamp = System.currentTimeMillis()
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, timestamp, peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.OUTGOING_RING, true)
val call = SignalDatabase.calls.getCallById(callId, peer)
val message = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
assertNotEquals(0L, message.expireStarted)
}
private fun insertTwoCallEvents() {
SignalDatabase.calls.insertAcceptedGroupCall(
1,
@@ -992,4 +1071,10 @@ class CallTableTest {
2000
)
}
private fun insertExpiringThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
MmsHelper.insert(recipient = Recipient.resolved(recipientId), expiresIn = 30_000L, threadId = threadId)
SignalDatabase.threads.update(threadId, false)
}
}
@@ -1,50 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
class InAppPaymentTableTest {
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
}
@Test
fun givenACreatedInAppPayment_whenIUpdateToPending_thenIExpectPendingPayment() {
val inAppPaymentId = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData()
)
val paymentBeforeUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentBeforeUpdate?.state).isEqualTo(InAppPaymentTable.State.CREATED)
SignalDatabase.inAppPayments.update(
inAppPayment = paymentBeforeUpdate!!.copy(state = InAppPaymentTable.State.PENDING)
)
val paymentAfterUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentAfterUpdate?.state).isEqualTo(InAppPaymentTable.State.PENDING)
}
}
@@ -1,174 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.UuidUtil
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.StorageId
import java.time.DayOfWeek
import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile as RemoteNotificationProfile
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
@RunWith(AndroidJUnit4::class)
class NotificationProfileTablesTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var alice: RecipientId
private lateinit var profile1: NotificationProfile
@Before
fun setUp() {
alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
profile1 = NotificationProfile(
id = 1,
name = "profile1",
emoji = "",
createdAt = 1000L,
schedule = NotificationProfileSchedule(id = 1),
allowedMembers = setOf(alice),
notificationProfileId = NotificationProfileId.generate(),
deletedTimestampMs = 0,
storageServiceId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3))
)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileScheduleTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileAllowedMembersTable.TABLE_NAME)
}
@Test
fun givenARemoteProfile_whenIInsertLocally_thenIExpectAListWithThatProfile() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
allowedMembers = listOf(RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString()))),
allowAllMentions = false,
allowAllCalls = true,
scheduleEnabled = false,
scheduleStartTime = 900,
scheduleEndTime = 1700,
scheduleDaysEnabled = emptyList(),
deletedAtTimestampMs = 0
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
val actualProfiles = SignalDatabase.notificationProfiles.getProfiles()
assertEquals(listOf(profile1), actualProfiles)
}
@Test
fun givenAProfile_whenIDeleteIt_thenIExpectAnEmptyList() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
assertThat(SignalDatabase.notificationProfiles.getProfiles()).isEmpty()
assertThat(SignalDatabase.notificationProfiles.getProfile(profile.id))
}
@Test
fun givenADeletedProfile_whenIGetIt_thenIExpectItToStillHaveASchedule() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
val deletedProfile = SignalDatabase.notificationProfiles.getProfile(profile.id)!!
assertThat(deletedProfile.schedule.enabled).isFalse()
assertThat(deletedProfile.schedule.start).isEqualTo(900)
assertThat(deletedProfile.schedule.end).isEqualTo(1700)
assertThat(deletedProfile.schedule.daysEnabled, "Contains correct default days")
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)
}
@Test
fun givenNotificationProfiles_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
SignalDatabase.notificationProfiles.createProfile(
name = "Profile1",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
)
SignalDatabase.notificationProfiles.createProfile(
name = "Profile2",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 2000L
)
val existingMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, _) ->
SignalDatabase.notificationProfiles.applyStorageIdUpdate(id, StorageId.forNotificationProfile(StorageSyncHelper.generateKey()))
}
val updatedMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, storageId) ->
assertNotEquals(storageId, updatedMap[id])
}
}
@Test
fun givenAProfileDeletedOver30Days_whenICleanUp_thenIExpectItToNotHaveAStorageId() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
deletedAtTimestampMs = 1000L
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty()
}
private val NotificationProfileTables.NotificationProfileChangeResult.profile: NotificationProfile
get() = (this as NotificationProfileTables.NotificationProfileChangeResult.Success).notificationProfile
}
@@ -8,10 +8,17 @@ package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.nullIfBlank
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageRecordUpdate
@@ -19,8 +26,11 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.signalAci
import org.whispersystems.signalservice.api.storage.signalPni
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -60,4 +70,46 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
val messages = MessageTableTestUtils.getMessages(SignalDatabase.threads.getThreadIdFor(other.id)!!)
assertThat(messages.first().isIdentityDefault).isTrue()
}
@Test
fun givenAnAlreadySyncedContact_whenMarkedUnregistered_thenItSplitsAndPublishesTheSplit() {
// GIVEN a registered contact with aci+pni+e164 that is already in storage service (has a storageId)
val aci = ACI.from(UUID.randomUUID())
val pni = PNI.from(UUID.randomUUID())
val e164 = "+13334445555"
val id = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164)
SignalDatabase.recipients.markRegistered(id, aci)
val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(originalStorageId).isNotNull()
// Sanity: the record it currently publishes is whole + registered.
val before = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(before.signalAci).isEqualTo(aci)
assertThat(before.signalPni).isEqualTo(pni)
assertThat(before.unregisteredAtTimestamp).isEqualTo(0L)
// WHEN it is marked unregistered (which strips its pni/e164 and splits it)
SignalDatabase.recipients.markUnregistered(id)
// THEN its storageId rotates
val updatedStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(updatedStorageId).isNotNull()
assertThat(originalStorageId!!.contentEquals(updatedStorageId!!)).isFalse()
// THEN the published record is now ACI-only + unregistered
val after = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(after.signalAci).isEqualTo(aci)
assertThat(after.signalPni).isNull()
assertThat(after.e164.nullIfBlank()).isNull()
assertThat(after.unregisteredAtTimestamp > 0L).isTrue()
// THEN the number now lives on a separate PNI-only recipient, so no whole aci+pni+e164 record remains.
val byPni = SignalDatabase.recipients.getByPni(pni).get()
assertThat(byPni).isNotEqualTo(id)
val pniRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(byPni)!!).proto.contact!!
assertThat(pniRecord.signalAci).isNull()
assertThat(pniRecord.signalPni).isEqualTo(pni)
}
}
@@ -1,120 +0,0 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.SqlUtil
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.whispersystems.signalservice.api.push.DistributionId
import java.util.UUID
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
@RunWith(AndroidJUnit4::class)
class MyStoryMigrationTest {
@get:Rule val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
@Test
fun givenAValidMyStory_whenIMigrate_thenIExpectMyStoryToBeValid() {
// GIVEN
assertValidMyStoryExists()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenNoMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
deleteMyStory()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenA00000000DistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId("0000-0000")
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenARandomDistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId(UUID.randomUUID().toString())
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
private fun setMyStoryDistributionId(serializedId: String) {
SignalDatabase.rawDatabase.update(
DistributionListTables.LIST_TABLE_NAME,
contentValuesOf(
DistributionListTables.DISTRIBUTION_ID to serializedId
),
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun deleteMyStory() {
SignalDatabase.rawDatabase.delete(
DistributionListTables.LIST_TABLE_NAME,
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun assertValidMyStoryExists() {
SignalDatabase.rawDatabase.query(
DistributionListTables.LIST_TABLE_NAME,
SqlUtil.COUNT,
"_id = ? AND ${DistributionListTables.DISTRIBUTION_ID} = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY, DistributionId.MY_STORY.toString()),
null,
null,
null
).use {
if (it.moveToNext()) {
val count = it.getInt(0)
assertEquals("assertValidMyStoryExists: Query produced an unexpected count.", 1, count)
} else {
fail("assertValidMyStoryExists: Query did not produce a count.")
}
}
}
private fun runMigration() {
V151_MyStoryMigration.migrate(
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
SignalSQLiteDatabase(SignalDatabase.rawDatabase),
0,
1
)
}
}
@@ -3,19 +3,28 @@ package org.thoughtcrime.securesms.dependencies
import android.app.Application
import io.mockk.mockk
import io.mockk.spyk
import okhttp3.OkHttpClient
import org.signal.core.util.UptimeSleepTimer
import org.signal.core.util.billing.BillingApi
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.ResponderInterceptor
import org.thoughtcrime.securesms.testing.endpoints.ResponderWebSocketConnection
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.util.function.Supplier
import kotlin.time.Duration.Companion.seconds
/**
* Dependency provider used for instrumentation tests (aka androidTests).
@@ -41,23 +50,68 @@ 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()
}
override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi {
return mockk()
/**
* Adds the Stripe-matching [ResponderInterceptor] on top of the default client (which supplies the
* user agent + DNS), so `api.stripe.com` requests made by [org.signal.donations.StripeApi] are
* answered from the shared [MockEndpoints.responder] and never hit the real network.
*/
override fun provideOkHttpClient(): OkHttpClient {
return default.provideOkHttpClient()
.newBuilder()
.addInterceptor(ResponderInterceptor(MockEndpoints.responder))
.build()
}
/**
* Backs the real [org.whispersystems.signalservice.api.donations.DonationsApi] (and any other
* websocket API) with a [ResponderWebSocketConnection] driven by the shared [MockEndpoints.responder],
* so Signal-service requests are answered from the scenario's "world" and never hit the network.
*/
override fun provideAuthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.AuthenticatedWebSocket {
return SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideUnauthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.UnauthenticatedWebSocket {
return SignalWebSocket.UnauthenticatedWebSocket(
connectionFactory = { ResponderWebSocketConnection(MockEndpoints.responder) },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
/**
* Uses the test zk server's public params so credentials minted by [MockEndpoints] validate here.
* The real [ClientZkReceiptOperations.receiveReceiptCredential] validation still runs in the
* receipt-credential context job — only the params are swapped for test ones.
*/
override fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations {
return DonationTestServer.clientReceiptOperations
}
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!!
}
@@ -0,0 +1,279 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
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.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.io.ByteArrayInputStream
import java.util.Optional
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
@RunWith(AndroidJUnit4::class)
class ArchiveAttachmentReconciliationJobTest {
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalStore.backup.backupTier = MessageBackupTier.PAID
SignalStore.backup.hasBackupBeenUploaded = true
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis()
SignalStore.backup.localRestoreReconcilePending = false
mockkObject(BackupRepository)
mockkObject(ArchiveCommitAttachmentDeletesJob)
every { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
}
@After
fun tearDown() {
unmockkAll()
}
/**
* The core of the reconcile-first restore flow: a local restore resets everything to [AttachmentTable.ArchiveTransferState.NONE], so media that genuinely is
* on the CDN must be promoted back to FINISHED during reconciliation -- otherwise the backfill would needlessly re-upload it. This only happens while
* [localRestoreReconcilePending] is set, so it never runs in the common periodic reconciliation.
*/
@Test
fun givenLocalRestorePendingAndAttachmentOnCdn_whenIReconcile_thenIExpectItMarkedFinished() {
SignalStore.backup.localRestoreReconcilePending = true
val attachmentId = seedFinalizedAttachment("remote-key-1".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
/**
* Guards the reconcile-first promotion above: outside the local-restore flow (the common periodic reconciliation), NONE media that happens to be on the CDN is
* left alone, so we don't do the expensive mark-finished scan in the common case.
*/
@Test
fun givenNoLocalRestorePendingAndNoneAttachmentOnCdn_whenIReconcile_thenItStaysNone() {
val attachmentId = seedFinalizedAttachment("remote-key-common".toByteArray(), byteArrayOf(2, 3, 4, 5, 6))
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun givenFinishedAttachmentMissingFromCdn_whenIReconcile_thenIExpectItResetToNone() {
val attachmentId = seedFinalizedAttachment("remote-key-2".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
/**
* The eventual safety net: an ordinary (non-forced) periodic reconciliation, run after the sync interval has elapsed, heals the bad state on its own -- media
* in the snapshot but missing from the CDN is reset to [AttachmentTable.ArchiveTransferState.NONE] and a re-upload is enqueued -- with no help from the
* migration or the reconcile-first flow.
*/
@Test
fun givenFinishedMediaMissingFromCdn_whenAnOrdinaryPeriodicReconciliationRuns_thenItHealsToNoneAndReUploads() {
SignalStore.backup.lastAttachmentReconciliationTime = System.currentTimeMillis() - 60.days.inWholeMilliseconds
mockkObject(BackupMessagesJob)
every { BackupMessagesJob.enqueue() } just Runs
val attachmentId = seedFinalizedAttachment("remote-key-periodic".toByteArray(), byteArrayOf(1, 2, 3, 4, 5))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = false).run()
val healed = SignalDatabase.attachments.getAttachment(attachmentId)!!
assertThat(healed.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(healed.archiveCdn).isNull()
verify(exactly = 1) { BackupMessagesJob.enqueue() }
}
/**
* Reconciliation must only repair genuinely-broken state. Media that is actually present on the CDN stays [AttachmentTable.ArchiveTransferState.FINISHED], so
* we never needlessly reset (and therefore re-upload) media that was correctly archived.
*/
@Test
fun givenFinishedMediaStillOnCdn_whenIReconcile_thenItStaysFinished() {
val attachmentId = seedFinalizedAttachment("remote-key-on-cdn".toByteArray(), byteArrayOf(6, 7, 8, 9, 10))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
/**
* The other healing direction: media that is on the CDN but locally marked [AttachmentTable.ArchiveTransferState.NONE] and absent from the current snapshot is
* treated as a delete-candidate. Before deleting, reconciliation confirms it's still referenced locally and recovers it to
* [AttachmentTable.ArchiveTransferState.FINISHED] rather than deleting it from the CDN.
*/
@Test
fun givenNoneMediaOnCdnButNotInSnapshot_whenIReconcile_thenItIsRecoveredToFinished() {
val attachmentId = seedFinalizedAttachment("remote-key-flow2".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.NONE)
fakeCdnContains(attachmentId, cdn = 3)
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
@Test
fun givenFirstEverReconciliation_whenIForceIt_thenItStillRunsAndRepairs() {
SignalStore.backup.lastAttachmentReconciliationTime = -1
val attachmentId = seedFinalizedAttachment("remote-key-3".toByteArray(), byteArrayOf(11, 12, 13, 14, 15))
SignalDatabase.attachments.setArchiveTransferState(attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
commitSnapshotFor(attachmentId, cdn = 3)
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalDatabase.attachments.getAttachment(attachmentId)!!.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
}
@Test
fun givenLocalRestoreReconcilePending_whenReconcileCompletes_thenIExpectFlagCleared() {
SignalStore.backup.localRestoreReconcilePending = true
fakeCdnEmpty()
ArchiveAttachmentReconciliationJob(forced = true).run()
assertThat(SignalStore.backup.localRestoreReconcilePending).isFalse()
}
private fun seedFinalizedAttachment(remoteKey: ByteArray, data: ByteArray): AttachmentId {
val attachment = createAttachmentPointer(remoteKey, data.size)
val messageResult = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment)).get()
val attachmentId = messageResult.insertedAttachments!![attachment]!!
SignalDatabase.attachments.setTransferState(messageResult.messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED)
SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageResult.messageId, attachmentId, ByteArrayInputStream(data))
return attachmentId
}
private fun commitSnapshotFor(attachmentId: AttachmentId, cdn: Int) {
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
SignalDatabase.backupMediaSnapshots.writePendingMediaEntries(
listOf(MediaEntry(mediaId = mediaId, cdn = cdn, plaintextHash = plaintextHash, remoteKey = remoteKey, isThumbnail = false))
)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
}
private fun fakeCdnContains(attachmentId: AttachmentId, cdn: Int) {
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
val plaintextHash = attachment.dataHash!!.decodeBase64OrThrow()
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(
storedMediaObjects = listOf(StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
backupDir = null,
mediaDir = null,
cursor = null
)
)
}
private fun fakeCdnEmpty() {
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(storedMediaObjects = emptyList(), backupDir = null, mediaDir = null, cursor = null)
)
}
private fun createIncomingMessage(serverTime: Duration, attachment: Attachment): IncomingMessage {
return IncomingMessage(
type = MessageType.NORMAL,
from = harness.others[0],
body = null,
sentTimeMillis = serverTime.inWholeMilliseconds,
serverTimeMillis = serverTime.inWholeMilliseconds,
receivedTimeMillis = serverTime.inWholeMilliseconds,
attachments = listOf(attachment)
)
}
private fun createAttachmentPointer(key: ByteArray, size: Int): Attachment {
return PointerAttachment.forPointer(
pointer = Optional.of(
SignalServiceAttachmentPointer(
cdnNumber = 3,
remoteId = SignalServiceAttachmentRemoteId.V4("asdf"),
contentType = MediaUtil.IMAGE_JPEG,
key = key,
size = Optional.of(size),
preview = Optional.empty(),
width = 2,
height = 2,
digest = Optional.of(byteArrayOf()),
incrementalDigest = Optional.empty(),
incrementalMacChunkSize = 0,
fileName = Optional.of("file.jpg"),
voiceNote = false,
isBorderless = false,
isGif = false,
caption = Optional.empty(),
blurHash = Optional.empty(),
uploadTimestamp = 0,
uuid = null
)
)
).get()
}
}
@@ -15,14 +15,13 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.StreamUtil
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.UriAttachmentBuilder
import org.thoughtcrime.securesms.database.transformPropertiesForSentMediaQuality
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.Optional
@@ -40,7 +39,7 @@ class AttachmentCompressionJobTest {
StreamUtil.readFully(it)
}
val blob = BlobProvider.getInstance().forData(imageBytes).createForSingleSessionOnDisk(AppDependencies.application)
val blob = AppDependencies.blobs.forData(imageBytes).createForSingleSessionOnDisk(AppDependencies.application)
val firstPreUpload = createAttachment(1, blob, TransformProperties.empty())
val firstDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(firstPreUpload)
@@ -19,17 +19,31 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
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.thoughtcrime.securesms.testing.TestRemoteConfigFlag
import java.util.UUID
@RemoteConfigForTest(
flags = [
Flag(TestRemoteConfigFlag.INTERNAL_USER, "true"),
Flag(TestRemoteConfigFlag.DEFAULT_MAX_BACKOFF, "1")
]
)
class BackupDeleteJobTest {
@get:Rule
@@ -37,10 +51,6 @@ class BackupDeleteJobTest {
@Before
fun setUp() {
mockkObject(RemoteConfig)
every { RemoteConfig.internalUser } returns true
every { RemoteConfig.defaultMaxBackoff } returns 1000L
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } returns NetworkResult.Success(MessageBackupTier.PAID)
every { BackupRepository.deleteBackup() } returns NetworkResult.Success(Unit)
@@ -54,29 +64,24 @@ class BackupDeleteJobTest {
@Test
fun givenUserNotRegistered_whenIRun_thenIExpectFailure() {
mockkObject(SignalStore) {
every { SignalStore.account.isRegistered } returns false
SignalStore.account.setRegistered(false)
val job = BackupDeleteJob()
val job = BackupDeleteJob()
val result = job.run()
val result = job.run()
assertThat(result.isFailure).isTrue()
}
assertThat(result.isFailure).isTrue()
}
@Test
fun givenLinkedDevice_whenIRun_thenIExpectFailure() {
mockkObject(SignalStore) {
every { SignalStore.account.isRegistered } returns true
every { SignalStore.account.isLinkedDevice } returns true
SignalStore.account.deviceId = 2
val job = BackupDeleteJob()
val job = BackupDeleteJob()
val result = job.run()
val result = job.run()
assertThat(result.isFailure).isTrue()
}
assertThat(result.isFailure).isTrue()
}
@Test
@@ -155,10 +160,7 @@ class BackupDeleteJobTest {
@Test
fun givenMediaOffloaded_whenIRun_thenIExpectAwaitingMediaDownload() {
mockkObject(SignalDatabase)
every { SignalDatabase.attachments.getRemainingRestorableAttachmentSize() } returns 1
every { SignalDatabase.attachments.getOptimizedMediaAttachmentSize() } returns 1
every { SignalDatabase.attachments.clearAllArchiveData() } returns Unit
insertOffloadedAttachment()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -252,4 +254,39 @@ class BackupDeleteJobTest {
assertThat(result.isRetry).isTrue()
}
private fun insertOffloadedAttachment(size: Long = 100) {
SignalDatabase.attachments.insertAttachmentsForMessage(
mmsId = 1,
attachments = listOf(
PointerAttachment(
contentType = "image/jpeg",
transferState = AttachmentTable.TRANSFER_RESTORE_OFFLOADED,
size = size,
fileName = null,
cdn = Cdn.CDN_3,
location = "somelocation",
key = Base64.encodeWithPadding(Util.getSecretBytes(64)),
iv = null,
digest = Util.getSecretBytes(64),
incrementalDigest = null,
incrementalMacChunkSize = 0,
fastPreflightId = null,
voiceNote = false,
borderless = false,
videoGif = false,
width = 100,
height = 100,
uploadTimestamp = System.currentTimeMillis(),
caption = null,
stickerLocator = null,
blurHash = null,
uuid = UUID.randomUUID(),
quote = false,
quoteTargetContentType = null
)
),
quoteAttachment = emptyList()
)
}
}
@@ -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
@@ -40,10 +42,10 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
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.thoughtcrime.securesms.testing.TestRemoteConfigFlag
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
@@ -55,6 +57,7 @@ import java.util.Currency
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.INTERNAL_USER, "true") ])
@RunWith(AndroidJUnit4::class)
class BackupSubscriptionCheckJobTest {
@@ -67,9 +70,6 @@ class BackupSubscriptionCheckJobTest {
@Before
fun setUp() {
mockkObject(RemoteConfig)
every { RemoteConfig.internalUser } returns true
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
@@ -88,6 +88,7 @@ class BackupSubscriptionCheckJobTest {
every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success(
createActiveSubscription()
)
every { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } returns Unit
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } answers {
@@ -120,8 +121,6 @@ class BackupSubscriptionCheckJobTest {
)
)
every { AppDependencies.donationsApi.putSubscription(any()) } returns NetworkResult.Success(Unit)
insertSubscriber()
}
@@ -142,26 +141,22 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenUserIsNotRegistered_whenIRun_thenIExpectSuccessAndEarlyExit() {
mockkObject(SignalStore.account) {
every { SignalStore.account.isRegistered } returns false
SignalStore.account.setRegistered(false)
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertEarlyExit(result)
}
assertEarlyExit(result)
}
@Test
fun givenIsLinkedDevice_whenIRun_thenIExpectSuccessAndEarlyExit() {
mockkObject(SignalStore.account) {
every { SignalStore.account.isLinkedDevice } returns true
SignalStore.account.deviceId = 2
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
assertEarlyExit(result)
}
assertEarlyExit(result)
}
@Test
@@ -0,0 +1,847 @@
/*
* 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.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
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.detailLocation: ${vm.detailLocation.value}")
appendLine("vm.chatsBackStackEntries: ${vm.chatsBackStackEntries.toList()}")
}
}
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() }
val staged = runOnMainSync { vm.chatsBackStackEntries.filterNot { it is MainNavigationDetailLocation.Empty } }
check(staged.isEmpty()) {
"Expected no detail to be staged on the chats back stack, got $staged"
}
}
}
@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)
val detailLocation = runOnMainSync { vm.detailLocation.value }
check(detailLocation == MainNavigationDetailLocation.Empty) {
"Expected Empty detail location, got $detailLocation"
}
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
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
await(description = "conversation cleared from chats back stack after Empty detail intent") {
vm.chatsBackStackEntries.none { it is MainNavigationDetailLocation.Conversation }
}
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 AppDependencies.blobs
.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
}
@@ -0,0 +1,84 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
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.util.deleteAll
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.VersionTracker
/**
* The "user might be a sustainer" rule for the donations remote megaphone: the `standard_donate`
* conditional (via [RemoteMegaphoneRepository.getRemoteMegaphoneToShow]) suppresses the megaphone for
* existing donors and shows it otherwise.
*/
@RunWith(AndroidJUnit4::class)
class DonationMegaphoneGatingTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.remoteMegaphones.debugRemoveAll()
setSelfBadges(emptyList())
// Freshly-installed test APKs report 0 days installed and no configured payment methods, both of
// which independently fail shouldShowDonateMegaphone. Fix them so the donor badge is the only
// variable across the gating tests.
mockkStatic(VersionTracker::class)
mockkObject(InAppDonations)
every { VersionTracker.getDaysSinceFirstInstalled(any()) } returns 30L
every { InAppDonations.hasAtLeastOnePaymentMethodAvailable() } returns true
}
@After
fun tearDown() {
unmockkStatic(VersionTracker::class)
unmockkObject(InAppDonations)
}
@Test
fun nonDonor_showsStandardDonateMegaphone() {
val record = donateMegaphoneRecord(conditionalId = "standard_donate")
SignalDatabase.remoteMegaphones.insert(record)
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()?.uuid).isEqualTo(record.uuid)
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isTrue()
}
@Test
fun sustainer_donorBadge_suppressesStandardDonateMegaphone() {
SignalDatabase.remoteMegaphones.insert(donateMegaphoneRecord(conditionalId = "standard_donate"))
setSelfBadges(listOf(donorBadge()))
assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()).isNull()
assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isFalse()
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.fragment.app.DialogFragment
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getSerializableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
/**
* Entry-path coverage for the donations remote megaphone: the megaphone shown on the conversation list
* that carries a "Donate" action into the one-time checkout flow.
*
* The megaphone renders through [MegaphoneComponent], so this renders that composable directly (rather
* than launching the full [org.thoughtcrime.securesms.MainActivity]) and asserts that tapping Donate
* runs the production [RemoteMegaphoneRepository] action, which navigates to [CheckoutFlowActivity]
* with [InAppPaymentType.ONE_TIME_DONATION]. The sustainer gating is covered by [DonationMegaphoneGatingTest].
*/
@RunWith(AndroidJUnit4::class)
class DonationMegaphoneTest {
@get:Rule
val harness = SignalActivityRule()
@get:Rule
val iapRule = InAppPaymentsRule()
@get:Rule
val composeRule = createComposeRule()
private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
SignalDatabase.remoteMegaphones.debugRemoveAll()
}
@Test
fun donateMegaphone_donateClick_opensOneTimeCheckout() {
val record = donateMegaphoneRecord(conditionalId = null)
SignalDatabase.remoteMegaphones.insert(record)
val controller = RecordingMegaphoneActionController()
composeRule.setContent {
SignalTheme {
MegaphoneComponent(buildDonateMegaphone(record), controller)
}
}
composeRule.onNodeWithText(record.primaryActionText!!).performClick()
val intent = controller.navigationIntent
assertThat(intent).isNotNull()
assertThat(intent!!.component?.className).isEqualTo(CheckoutFlowActivity::class.java.name)
val type = intent.extras!!.getSerializableCompat(CheckoutFlowActivity.ARG_IN_APP_PAYMENT_TYPE, InAppPaymentType::class.java)
assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION)
}
/** Mirrors [Megaphones.buildRemoteMegaphone]: wires the primary button to the real repository action. */
private fun buildDonateMegaphone(record: RemoteMegaphoneRecord): Megaphone {
return Megaphone.Builder(Megaphones.Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC)
.setTitle(record.title)
.setBody(record.body)
.setActionButton(record.primaryActionText!!) { _, controller ->
RemoteMegaphoneRepository.getAction(record.primaryActionId!!).run(context, controller, record)
}
.build()
}
private class RecordingMegaphoneActionController : MegaphoneActionController {
var navigationIntent: Intent? = null
private set
override fun onMegaphoneNavigationRequested(intent: Intent) {
navigationIntent = intent
}
override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) {
navigationIntent = intent
}
override fun onMegaphoneToastRequested(string: String) = Unit
override fun getMegaphoneActivity(): Activity = throw UnsupportedOperationException("not used")
override fun onMegaphoneSnooze(event: Megaphones.Event) = Unit
override fun onMegaphoneCompleted(event: Megaphones.Event) = Unit
override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) = Unit
}
}
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.net.Uri
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.UUID
import java.util.concurrent.TimeUnit
fun donateMegaphoneRecord(conditionalId: String?): RemoteMegaphoneRecord {
val now = System.currentTimeMillis()
return RemoteMegaphoneRecord(
priority = 100,
uuid = UUID.randomUUID().toString(),
countries = null,
minimumVersion = 1,
doNotShowBefore = now - TimeUnit.DAYS.toMillis(2),
doNotShowAfter = now + TimeUnit.DAYS.toMillis(28),
showForNumberOfDays = 30,
conditionalId = conditionalId,
primaryActionId = RemoteMegaphoneRecord.ActionId.DONATE,
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
imageUrl = null,
title = "Donate Test",
body = "Donate body test.",
primaryActionText = "Donate",
secondaryActionText = "Snooze"
)
}
fun donorBadge(): Badge {
return Badge(
id = "test-donor-badge",
category = Badge.Category.Donor,
name = "Signal Sustainer",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "xxhdpi",
expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30),
visible = true,
duration = TimeUnit.DAYS.toMillis(30)
)
}
fun setSelfBadges(badges: List<Badge>) {
SignalDatabase.recipients.setBadges(Recipient.self().id, badges)
Recipient.self().fresh()
}
@@ -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()
@@ -18,10 +18,10 @@ import org.thoughtcrime.securesms.database.UriAttachmentBuilder
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.ThreadUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
@@ -120,7 +120,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
}
fun outgoingAttachment(data: ByteArray, uuid: UUID? = UUID.randomUUID()): Attachment {
val uri: Uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val uri: Uri = AppDependencies.blobs.forData(data).createForSingleSessionInMemory()
val attachment: UriAttachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
@@ -0,0 +1,347 @@
/*
* 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 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.database.AttachmentId
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.AddressableMessage
import org.whispersystems.signalservice.internal.push.AttachmentPointer
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.ConversationIdentifier
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_attachmentBackfill {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var messageHelper: MessageHelper
private var originalDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
originalDeviceId = SignalStore.account.deviceId
// Make this device a linked device so backfill response handling activates.
SignalStore.account.deviceId = 2
// Prevent AttachmentDownloadJob onAdded from async changing the attachment state.
TextSecurePreferences.getSharedPreferences(harness.application)
.edit()
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_WIFI_PREF, emptySet())
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_MOBILE_PREF, emptySet())
.putStringSet(TextSecurePreferences.MEDIA_DOWNLOAD_ROAMING_PREF, emptySet())
.commit()
}
@After
fun tearDown() {
SignalStore.account.deviceId = originalDeviceId
messageHelper.tearDown()
}
@Test
fun fresh_pointer_updates_row_and_resets_transfer_state() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
val pointer = freshPointer(cdnNumber = 3, cdnKey = "fresh-key", size = 1234, uploadTimestamp = 9_999_000L)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = pointer))
)
// transferState is not asserted: the forced download job's onAdded() races it PENDING -> STARTED. The pointer fields
// are written synchronously and are stable.
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.remoteLocation).isEqualTo("fresh-key")
assertThat(refreshed.cdn.cdnNumber).isEqualTo(3)
assertThat(refreshed.size).isEqualTo(1234L)
assertThat(refreshed.uploadTimestamp).isEqualTo(9_999_000L)
}
@Test
fun terminal_error_marks_permanent_failure() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(
status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.TERMINAL_ERROR
)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE)
}
@Test
fun pending_status_leaves_row_unchanged() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(
status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.PENDING
)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_FAILED)
}
@Test
fun message_not_found_error_marks_attachments_retryable_failed() {
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
error = SyncMessage.AttachmentBackfillResponse.Error.MESSAGE_NOT_FOUND
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_FAILED)
}
@Test
fun primary_device_ignores_backfill_response() {
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
val (messageId, attachmentId) = insertIncomingMediaMessage(messageHelper.alice)
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(
status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.TERMINAL_ERROR
)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_FAILED)
}
@Test
fun multi_attachment_response_matches_positionally_with_mixed_status() {
val messageId = insertIncomingMessageWith(messageHelper.alice, listOf(incomingImagePointer(), incomingImagePointer()))
val body = SignalDatabase.attachments.getAttachmentsForMessage(messageId).sortedBy { it.displayOrder }
assertThat(body.size).isEqualTo(2)
body.forEach { SignalDatabase.attachments.setTransferProgressFailed(it.attachmentId, messageId) }
// Response is a positional array: index 0 -> body[0] (fresh pointer), index 1 -> body[1] (terminal).
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "first-key", size = 11, uploadTimestamp = 111L)),
SyncMessage.AttachmentBackfillResponse.AttachmentData(status = SyncMessage.AttachmentBackfillResponse.AttachmentData.Status.TERMINAL_ERROR)
)
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).sortedBy { it.displayOrder }
// remoteLocation proves index 0 routed to body[0]. transferState is not asserted: it races the download job's onAdded().
assertThat(refreshed[0].remoteLocation).isEqualTo("first-key")
assertThat(refreshed[0].cdn.cdnNumber).isEqualTo(3)
assertThat(refreshed[1].transferState).isEqualTo(AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE)
}
@Test
fun long_text_slot_is_applied_independently_of_the_body() {
val messageId = insertIncomingMessageWith(messageHelper.alice, listOf(incomingImagePointer(), incomingLongTextPointer()))
val all = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
all.forEach { SignalDatabase.attachments.setTransferProgressFailed(it.attachmentId, messageId) }
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "body-key", size = 22, uploadTimestamp = 222L))),
longText = SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "long-text-key", size = 33, uploadTimestamp = 333L))
)
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
val bodyRow = refreshed.single { it.contentType != MediaUtil.LONG_TEXT }
val longTextRow = refreshed.single { it.contentType == MediaUtil.LONG_TEXT }
// The positional `attachments` array fills the body row and the separate `longText` slot fills the long-text row,
// with no cross-contamination. transferState is not asserted: it races the download job's onAdded().
assertThat(bodyRow.remoteLocation).isEqualTo("body-key")
assertThat(longTextRow.remoteLocation).isEqualTo("long-text-key")
}
@Test
fun remote_attachment_list_longer_than_local_skips_extras() {
val messageId = insertIncomingMessageWith(messageHelper.alice, listOf(incomingImagePointer()))
val attachmentId = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single().attachmentId
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
deliverBackfillResponse(
sender = messageHelper.alice,
sentTimestamp = sentTimestampFor(messageId),
conversationId = messageHelper.alice,
attachmentData = listOf(
SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "only-key", size = 44, uploadTimestamp = 444L)),
SyncMessage.AttachmentBackfillResponse.AttachmentData(attachment = freshPointer(cdnNumber = 3, cdnKey = "extra-key", size = 55, uploadTimestamp = 555L))
)
)
// The single local row is routed from index 0; the extra index-1 entry has no body[1] and must be skipped, not throw.
val refreshed = SignalDatabase.attachments.getAttachmentsForMessage(messageId).single()
assertThat(refreshed.remoteLocation).isEqualTo("only-key")
}
private fun insertIncomingMediaMessage(sender: RecipientId): Pair<Long, AttachmentId> {
messageHelper.startTime = messageHelper.nextStartTime()
val sentTimestamp = messageHelper.startTime
val content = Content.Builder()
.dataMessage(
DataMessage.Builder()
.timestamp(sentTimestamp)
.attachments(listOf(MessageContentFuzzer.attachmentPointer()))
.build()
)
.build()
messageHelper.processor.process(
envelope = MessageContentFuzzer.envelope(sentTimestamp),
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(source = sender, destination = harness.self.id),
serverDeliveredTimestamp = sentTimestamp + 10
)
val syncMessageId = MessageTable.SyncMessageId(sender, sentTimestamp)
val messageId = SignalDatabase.messages.getMessageIdOrNull(syncMessageId)
assertThat(messageId, name = "messageId").isNotNull()
val attachment = SignalDatabase.attachments.getAttachmentsForMessage(messageId!!).single()
return messageId to attachment.attachmentId
}
private fun insertIncomingMessageWith(sender: RecipientId, pointers: List<AttachmentPointer>): Long {
messageHelper.startTime = messageHelper.nextStartTime()
val sentTimestamp = messageHelper.startTime
val content = Content.Builder()
.dataMessage(
DataMessage.Builder()
.timestamp(sentTimestamp)
.attachments(pointers)
.build()
)
.build()
messageHelper.processor.process(
envelope = MessageContentFuzzer.envelope(sentTimestamp),
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(source = sender, destination = harness.self.id),
serverDeliveredTimestamp = sentTimestamp + 10
)
val messageId = SignalDatabase.messages.getMessageIdOrNull(MessageTable.SyncMessageId(sender, sentTimestamp))
assertThat(messageId, name = "messageId").isNotNull()
return messageId!!
}
private fun incomingImagePointer(): AttachmentPointer = MessageContentFuzzer.attachmentPointer().newBuilder().contentType("image/jpeg").build()
private fun incomingLongTextPointer(): AttachmentPointer = MessageContentFuzzer.attachmentPointer().newBuilder().contentType(MediaUtil.LONG_TEXT).build()
private fun sentTimestampFor(messageId: Long): Long {
return SignalDatabase.messages.getMessageRecord(messageId).dateSent
}
private fun deliverBackfillResponse(
sender: RecipientId,
sentTimestamp: Long,
conversationId: RecipientId,
attachmentData: List<SyncMessage.AttachmentBackfillResponse.AttachmentData> = emptyList(),
longText: SyncMessage.AttachmentBackfillResponse.AttachmentData? = null,
error: SyncMessage.AttachmentBackfillResponse.Error? = null
) {
messageHelper.startTime = messageHelper.nextStartTime()
val envelopeTimestamp = messageHelper.startTime
val response = SyncMessage.AttachmentBackfillResponse(
targetMessage = AddressableMessage(
authorServiceIdBinary = Recipient.resolved(sender).requireAci().toByteString(),
sentTimestamp = sentTimestamp
),
targetConversation = ConversationIdentifier(
threadServiceIdBinary = Recipient.resolved(conversationId).requireAci().toByteString()
),
attachments = if (error == null) SyncMessage.AttachmentBackfillResponse.AttachmentDataList(attachments = attachmentData, longText = longText) else null,
error = error
)
val content = Content.Builder()
.syncMessage(SyncMessage.Builder().attachmentBackfillResponse(response).build())
.build()
messageHelper.processor.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp, serverGuid = UUID.randomUUID()),
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(source = harness.self.id, destination = harness.self.id, sourceDeviceId = 1),
serverDeliveredTimestamp = envelopeTimestamp + 10
)
}
private fun freshPointer(cdnNumber: Int, cdnKey: String, size: Int, uploadTimestamp: Long): AttachmentPointer {
return AttachmentPointer.Builder()
.cdnKey(cdnKey)
.cdnNumber(cdnNumber)
.key(Base64.decode("AAAAAAAA").toByteString())
.digest(ByteArray(32) { it.toByte() }.toByteString())
.size(size)
.uploadTimestamp(uploadTimestamp)
.contentType("image/jpeg")
.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,383 @@
/*
* 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 skipsRedeliveryWithSameServerTimestamp() {
val timestamp = messageHelper.nextStartTime()
sendPniChangeNumber(timestamp = timestamp)
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 = timestamp
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun reappliesWhenServerTimestampIsNewer() {
sendPniChangeNumber()
val secondPniUuid = UUID.randomUUID()
val secondPni = ServiceId.PNI.from(secondPniUuid)
val secondPniBytes = UuidUtil.toByteArray(secondPniUuid).toByteString()
val secondIdentity = IdentityKeyPair.generate()
val secondSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, secondIdentity.privateKey)
val secondE164 = "+15555550100"
val secondRegistrationId = 7777
sendPniChangeNumber(
identityKeyPair = secondIdentity.serialize().toByteString(),
signedPreKey = secondSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey = null,
registrationId = secondRegistrationId,
e164 = secondE164,
envelopePniBinary = secondPniBytes,
timestamp = messageHelper.nextStartTime() + 1000
)
assertThat(SignalStore.account.e164).isEqualTo(secondE164)
assertThat(SignalStore.account.pni).isEqualTo(secondPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(secondRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(secondIdentity.publicKey.serialize().toByteString())
}
@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")
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing
import com.google.android.gms.wallet.PaymentData
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import io.reactivex.rxjava3.core.Completable
import org.junit.rules.ExternalResource
import org.signal.donations.GooglePayApi
/**
* Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without
* launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation.
*/
class GooglePayTestRule : ExternalResource() {
override fun before() {
val paymentData = mockk<PaymentData> {
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
}
mockkConstructor(GooglePayApi::class)
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
arg<GooglePayApi.PaymentRequestCallback>(4).onSuccess(paymentData)
}
}
override fun after() {
unmockkConstructor(GooglePayApi::class)
}
companion object {
/**
* Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses
* `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is
* serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs.
*/
const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
}
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import io.reactivex.rxjava3.schedulers.TestScheduler
/**
* Timing primitives for in-app-payment instrumentation tests.
*
* The checkout flow runs against two clocks: the Rx pipeline that drives the UI (creating the payment,
* enqueuing the setup job, reacting to state changes, rendering dialogs) runs on the test's [TestScheduler]
* (virtual time), while the setup job itself executes on a live JobManager worker thread (real time). Neither
* clock can be waited on alone — the scheduler must be advanced for the pipeline to make progress, and the
* test must yield real time for the job to run and commit its result. [flushUntil] does both.
*/
/**
* Advances virtual Rx time on this [TestScheduler], drains the main looper, and yields briefly for real
* JobManager work, repeating until [condition] holds or [timeoutMs] elapses.
*
* [TestScheduler.triggerActions] runs the pipeline's scheduled work (which may enqueue a job or process a
* database update), `waitForIdleSync` renders whatever that produced, and the short sleep lets the real setup
* job run on its worker thread and commit the next state before the loop advances the scheduler again to pick
* it up. Throws once [timeoutMs] is exceeded, which means the expected UI state never materialised.
*
* [condition] may either return `false` or throw (e.g. an Espresso `check` that has not yet matched) to signal
* "not satisfied"; a thrown failure is retained and chained as the cause of the timeout [AssertionError] so a
* flake surfaces the underlying Espresso error rather than an opaque "condition not satisfied".
*/
fun TestScheduler.flushUntil(timeoutMs: Long = 15_000, condition: () -> Boolean) {
val deadline = SystemClock.uptimeMillis() + timeoutMs
var lastFailure: Throwable? = null
while (true) {
triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
try {
if (condition()) {
return
}
} catch (t: Throwable) {
lastFailure = t
}
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError("Condition was not satisfied within ${timeoutMs}ms of flushing the checkout pipeline.", lastFailure)
}
Thread.sleep(50)
}
}
@@ -7,37 +7,90 @@ package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import org.json.JSONObject
import org.junit.rules.ExternalResource
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
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
import org.thoughtcrime.securesms.testing.endpoints.EndpointResponse
import org.thoughtcrime.securesms.testing.endpoints.MockEndpoints
import org.thoughtcrime.securesms.testing.endpoints.delete
import org.thoughtcrime.securesms.testing.endpoints.get
import org.thoughtcrime.securesms.testing.endpoints.ok
import org.thoughtcrime.securesms.testing.endpoints.post
import org.thoughtcrime.securesms.testing.endpoints.put
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
/**
* Sets up some common infrastructure for on-device InAppPayment testing
* Sets up common infrastructure for on-device InAppPayment testing.
*
* The Signal-service edge is driven through the shared [MockEndpoints.responder] (the real
* [org.whispersystems.signalservice.api.donations.DonationsApi] runs against these handlers), while
* account/archive lookups that are not websocket-backed here stay mocked via mockk. A scenario can
* override any default by registering a more specific handler after this rule runs (last-registered
* wins).
*/
class InAppPaymentsRule : ExternalResource() {
override fun before() {
initialiseConfigurationResponse()
initialisePutSubscription()
MockEndpoints.responder.clear()
registerDonationServiceDefaults()
initialiseSetArchiveBackupId()
initialiseSetAccountAttributes()
initialiseAccountLookups()
}
private fun initialiseConfigurationResponse() {
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
val response = assets.open("inAppPaymentsTests/configuration.json").use { stream ->
NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
}
AppDependencies.donationsApi.apply {
every { getDonationsConfiguration(any()) } returns response
}
override fun after() {
MockEndpoints.responder.clear()
}
private fun initialisePutSubscription() {
AppDependencies.donationsApi.apply {
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
/**
* Default happy-path responses for the Signal donations endpoints. Registered generic-to-specific
* so more specific paths registered later win.
*/
private fun registerDonationServiceDefaults() {
val responder = MockEndpoints.responder
// Subscriber lifecycle: create/put/update-level (PUT), cancel (DELETE) all succeed by default.
responder.put("/v1/subscription/") { ok() }
responder.delete("/v1/subscription/") { ok() }
// getSubscription defaults to "no active subscription".
responder.get("/v1/subscription/") { ok(DonationResponses.emptySubscription()) }
// Donations configuration (registered after the generic GET so it wins for its path).
val configuration = InstrumentationRegistry.getInstrumentation().context.resources.assets
.open("inAppPaymentsTests/configuration.json")
.use { it.readBytes().decodeToString() }
responder.get("/v1/subscription/configuration") { ok(configuration) }
// SEPA bank-transfer mandate text (registered after the generic GET so it wins for its path).
responder.get("/v1/subscription/bank_mandate/") { ok(DonationResponses.bankMandate()) }
// One-time (boost) payment intent.
responder.post("/v1/subscription/boost/create") { ok(DonationResponses.stripeClientSecret()) }
// Recurring payment method setup + default payment method + level are keyed by subscriber id in
// the middle of the path, so match on a distinctive suffix.
responder.register({ it.method == "POST" && it.path.contains("/create_payment_method") && !it.path.contains("paypal") }) {
ok(DonationResponses.setupIntentClientSecret())
}
responder.register({ it.method == "POST" && it.path.contains("/default_payment_method") }) { ok() }
// Receipt credential submission (boost + recurring) — mint a valid credential from the client's request.
responder.register({ it.method == "POST" && it.path.contains("/receipt_credentials") }) { request ->
mintReceiptCredential(request.bodyString)
}
// Redemption.
responder.post("/v1/donation/redeem-receipt") { ok() }
}
private fun mintReceiptCredential(requestBody: String): EndpointResponse {
val requestBase64 = JSONObject(requestBody).getString("receiptCredentialRequest")
val minted = DonationTestServer.issueReceiptCredential(requestBase64)
return ok(DonationResponses.receiptCredential(minted))
}
private fun initialiseSetArchiveBackupId() {
@@ -45,4 +98,20 @@ class InAppPaymentsRule : ExternalResource() {
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
}
}
private fun initialiseSetAccountAttributes() {
AppDependencies.accountApi.apply {
every { setAccountAttributes(any()) } returns NetworkResult.Success(Unit)
}
}
/**
* whoAmI is served by the (still mocked) account API rather than the websocket responder, so
* background jobs that resolve the account during a test hit a handled path instead of throwing.
*/
private fun initialiseAccountLookups() {
AppDependencies.accountApi.apply {
every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123"))
}
}
}
@@ -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")
@@ -0,0 +1,82 @@
package org.thoughtcrime.securesms.testing
import org.json.JSONObject
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.reflect.KProperty0
import kotlin.reflect.jvm.isAccessible
/**
* Declares remote config values a test needs. The [SignalTestRunner] reads this off the
* about-to-run test (class and/or method) and stages the values into [TestRemoteConfig], which
* [org.thoughtcrime.securesms.SignalInstrumentationApplicationContext] seeds into the real
* [RemoteConfig] before the startup `init()` runs.
*
* Method-level annotations override class-level ones for the same key. Values are strings, matching
* how the service delivers config; `"true"`/`"false"` are decoded into real booleans on the way into
* the store (same as [org.signal.network.api.RemoteConfigApi]), other values stay strings.
*
* Prefer the typed [Flag] (which resolves its key from the actual [RemoteConfig] property); use
* [RawFlag] for keys that don't have a [TestRemoteConfigFlag] entry.
*
* ```
* @RemoteConfigForTest(
* flags = [Flag(TestRemoteConfigFlag.INTERNAL_USER, "true")],
* rawFlags = [RawFlag("android.someOtherKey", "1")]
* )
* class MyTest { ... }
* ```
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class RemoteConfigForTest(
val flags: Array<Flag> = [],
val rawFlags: Array<RawFlag> = []
)
/** A flag whose key is resolved from the referenced [RemoteConfig] property at runtime. */
@Retention(AnnotationRetention.RUNTIME)
annotation class Flag(val flag: TestRemoteConfigFlag, val value: String)
/** A flag identified by its raw remote config key, for keys without a [TestRemoteConfigFlag] entry. */
@Retention(AnnotationRetention.RUNTIME)
annotation class RawFlag(val key: String, val value: String)
/**
* Typed handles for remote config flags referenced by tests.
*/
enum class TestRemoteConfigFlag(private val property: KProperty0<*>) {
INTERNAL_USER(RemoteConfig::internalUser),
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff),
DISAPPEAR_MORE(RemoteConfig::disappearMore);
val key: String
get() {
property.isAccessible = true
val delegate = property.getDelegate() ?: error("RemoteConfig.${property.name} has no delegate; only `by remoteX(...)` configs can be referenced by ${TestRemoteConfigFlag::class.simpleName}.")
check(delegate is RemoteConfig.Config<*>) {
"RemoteConfig.${property.name} delegate is ${delegate::class.simpleName}, not RemoteConfig.Config; cannot resolve its remote config key."
}
return delegate.key
}
}
/**
* Process-static bridge between [SignalTestRunner] (which knows the running test) and
* [org.thoughtcrime.securesms.SignalInstrumentationApplicationContext] (which seeds the config).
* Safe because Orchestrator runs each test in a fresh process.
*/
object TestRemoteConfig {
@Volatile
var pending: Map<String, Any> = emptyMap()
/**
* The staged config as a JSON string ready to write into `SignalStore.remoteConfig`. Mirrors
* [org.signal.network.api.RemoteConfigApi]'s decode so `"true"`/`"false"` land as real booleans
* (like the server path) while other values stay strings.
*/
val json: String
get() {
val decoded = pending.mapValues { (_, value) -> (value as? String)?.lowercase()?.toBooleanStrictOrNull() ?: value }
return JSONObject(decoded).toString()
}
}
@@ -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()
@@ -2,15 +2,62 @@ package org.thoughtcrime.securesms.testing
import android.app.Application
import android.content.Context
import android.os.Bundle
import androidx.test.runner.AndroidJUnitRunner
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
/**
* Custom runner that replaces application with [SignalInstrumentationApplicationContext].
*
* Before the application is created, it reads any [RemoteConfigForTest] declared on the
* about-to-run test (passed by the Orchestrator as the `class` argument, `pkg.Class#method`) and
* stages the values in [TestRemoteConfig] so the app can seed them into `RemoteConfig` at startup.
*/
@Suppress("unused")
class SignalTestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: Bundle?) {
TestRemoteConfig.pending = parseRemoteConfig(arguments?.getString("class"))
super.onCreate(arguments)
}
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, SignalInstrumentationApplicationContext::class.java.name, context)
}
/**
* Resolves [RemoteConfigForTest] annotations from the targeted test(s). [classArg] is the
* instrumentation `class` argument: a comma-separated list of `pkg.Class` or `pkg.Class#method`.
* Method-level flags override class-level flags for the same key. Reflection failures (e.g. a
* whole-suite run with no `class` arg) fall back to no overrides.
*/
private fun parseRemoteConfig(classArg: String?): Map<String, Any> {
if (classArg.isNullOrBlank()) {
return emptyMap()
}
val flags = mutableMapOf<String, Any>()
for (entry in classArg.split(",")) {
val (className, methodName) = entry.trim().split("#", limit = 2).let { it[0] to it.getOrNull(1) }
try {
// initialize = false: only read annotations, don't run the test class's static init this early.
val testClass = Class.forName(className, false, javaClass.classLoader)
val method = methodName?.let { name -> testClass.declaredMethods.firstOrNull { it.name == name } }
// Class annotation first, then method annotation so method-level flags override class-level ones.
listOfNotNull(
testClass.getAnnotation(RemoteConfigForTest::class.java),
method?.getAnnotation(RemoteConfigForTest::class.java)
).forEach { annotation ->
annotation.flags.forEach { flags[it.flag.key] = it.value }
annotation.rawFlags.forEach { flags[it.key] = it.value }
}
} catch (_: ReflectiveOperationException) {
// Class/method not resolvable in this run; leave overrides as-is.
}
}
return flags
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing.actions
import android.os.SystemClock
import android.view.View
import androidx.annotation.IdRes
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.PerformException
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.platform.app.InstrumentationRegistry
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.hamcrest.Matcher
/**
* Scrolls the [RecyclerView] with id [recyclerViewId] to the view holder whose item view matches [target], or
* whose item view contains a descendant matching [target] (e.g. a preset button inside a container row),
* binding it if necessary, then returns. Off-screen presets/buttons are brought on-screen before a click or
* assertion regardless of device size — important for Firebase Test Lab's varied screens.
*
* The DSL screens use [androidx.recyclerview.widget.ListAdapter], which diffs `submitList` on a background
* thread and posts the result to the main thread. espresso-contrib's [RecyclerViewActions.scrollTo] scans the
* adapter once and fails fast if that diff has not yet committed, so we retry within [timeoutMs], pumping the
* main looper (and, if supplied, advancing [scheduler] to run the Rx work that produces the list) between
* attempts. This is the async-diff analogue of the codebase's existing poll-until-ready test idiom; Android
* exposes no deterministic completion hook for the differ.
*/
fun scrollToDescendant(
@IdRes recyclerViewId: Int,
target: Matcher<View>,
scheduler: TestScheduler? = null,
timeoutMs: Long = 5_000
) {
val deadline = SystemClock.uptimeMillis() + timeoutMs
while (true) {
// A holder's item view may itself be the target (a bare button row) or contain it as a descendant
// (a preset within a container), so try both rather than assume one shape.
if (tryScrollTo(recyclerViewId, target) || tryScrollTo(recyclerViewId, hasDescendant(target))) {
return
}
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError("RecyclerView (id=$recyclerViewId) never bound a holder matching $target within ${timeoutMs}ms.")
}
scheduler?.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
Thread.sleep(50)
}
}
private fun tryScrollTo(@IdRes recyclerViewId: Int, holderMatcher: Matcher<View>): Boolean {
return try {
onView(withId(recyclerViewId)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(holderMatcher))
true
} catch (e: PerformException) {
false
}
}
@@ -1,34 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing.actions
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.Matcher
/**
* Scrolls the RecyclerView to the bottom position.
*
* Borrowed from [https://stackoverflow.com/a/55990445](https://stackoverflow.com/a/55990445)
*/
object RecyclerViewScrollToBottomAction : ViewAction {
override fun getDescription(): String = "scroll RecyclerView to bottom"
override fun getConstraints(): Matcher<View> = allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed())
override fun perform(uiController: UiController?, view: View?) {
val recyclerView = view as RecyclerView
val itemCount = recyclerView.adapter?.itemCount
val position = itemCount?.minus(1) ?: 0
recyclerView.scrollToPosition(position)
uiController?.loopMainThreadUntilIdle()
}
}
@@ -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)
}
}
}
}
@@ -16,6 +16,7 @@ import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.signal.network.websocket.WebSocketRequestMessage
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -23,7 +24,6 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import kotlin.random.Random
/**
@@ -32,6 +32,14 @@ class BenchmarkSetupActivity : BaseActivity() {
companion object {
private val TAG = Log.tag(BenchmarkSetupActivity::class)
const val SEARCH_KEYWORD = "lighthouse"
private val SEARCH_VOCABULARY = listOf(
"hello", "world", "signal", "android", "kotlin", "database", "benchmark", "conversation",
"morning", "evening", "weekend", "project", "meeting", "dinner", "coffee", "garden",
"mountain", "river", "forest", "harbor", "market", "library", "concert", "holiday"
)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -51,6 +59,7 @@ class BenchmarkSetupActivity : BaseActivity() {
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"conversation-list-search" -> setupConversationListSearch()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
@@ -97,6 +106,39 @@ class BenchmarkSetupActivity : BaseActivity() {
}
}
private fun setupConversationListSearch() {
TestUsers.setupSelf()
val recipientCount = 50
val messagesPerRecipient = 2000
val totalMessages = recipientCount * messagesPerRecipient
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (totalMessages * 2000L) - 60_000L)
TestUsers.setupTestRecipients(recipientCount).forEachIndexed { recipientIndex, recipientId ->
val recipient: Recipient = Recipient.resolved(recipientId)
for (i in 0 until messagesPerRecipient) {
val body = searchableMessageBody(recipientIndex, i)
if (i % 2 == 0) {
TestMessages.insertIncomingTextMessage(other = recipient, body = body, timestamp = generator.nextTimestamp())
} else {
TestMessages.insertOutgoingTextMessage(other = recipient, body = body, timestamp = generator.nextTimestamp())
}
}
SignalDatabase.messages.setAllMessagesRead()
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
}
}
private fun searchableMessageBody(recipientIndex: Int, messageIndex: Int): String {
val words = SEARCH_VOCABULARY
val w1 = words[(recipientIndex + messageIndex) % words.size]
val w2 = words[(recipientIndex * 7 + messageIndex * 3) % words.size]
val w3 = words[(recipientIndex * 13 + messageIndex * 5) % words.size]
return "$w1 $w2 $SEARCH_KEYWORD $w3 message $messageIndex"
}
private fun setupMessageSend() {
TestUsers.setupSelf()
TestUsers.setupTestClients(1)
@@ -0,0 +1,83 @@
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 }
}
}
@@ -241,5 +241,6 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
override fun setMultiDevice(isMultiDevice: Boolean) = throw UnsupportedOperationException()
}
}
@@ -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.core.util.JsonUtils
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebSocketResponseMessage
import org.signal.network.websocket.WebsocketResponse
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>
+12 -11
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" />
@@ -439,12 +438,7 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".calls.links.details.CallLinkDetailsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:name=".calls.new.NewCallActivity"
@@ -528,6 +522,13 @@
</intent-filter>
</activity>
<activity
android:name=".preferences.EditProxyActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".stories.my.MyStoriesActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -1372,7 +1373,7 @@
<service
android:name=".gcm.FcmReceiveService"
android:exported="true">
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
@@ -1405,7 +1406,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"
@@ -1535,7 +1536,7 @@
</receiver>
<receiver
android:name="org.thoughtcrime.securesms.jobs.ForegroundServiceUtil$Receiver"
android:name="org.signal.core.util.ForegroundServiceUtil$Receiver"
android:exported="false" />
<receiver
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,11 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
private var afterScroll: (() -> Unit)? = null
// Backing state for scrollToPositionTopAligned; alignTopCorrected guards the one-shot corrective re-scroll.
private var alignTopPosition: Int = RecyclerView.NO_POSITION
private var alignTopInset: Int = 0
private var alignTopCorrected: Boolean = false
override fun supportsPredictiveItemAnimations(): Boolean {
return false
}
@@ -34,9 +39,23 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
*/
fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = RecyclerView.NO_POSITION
super.scrollToPositionWithOffset(position, offset)
}
/**
* Scroll so [position]'s decorated top (including any top decoration, e.g. the unread divider) lands [topInset] px
* below the top of the recycler. [afterScroll] fires once the alignment settles.
*/
fun scrollToPositionTopAligned(position: Int, topInset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = position
alignTopInset = topInset
alignTopCorrected = false
// Rough first pass: the exact offset needs the item's height, which isn't known until it's laid out (see onLayoutCompleted).
super.scrollToPositionWithOffset(position, height - topInset)
}
/**
* If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source,
* the base implementation clears the request as if it was never made.
@@ -64,10 +83,26 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
} else {
scrollToPosition(pendingScrollPosition)
}
} else {
afterScroll?.invoke()
afterScroll = null
return
}
// The target is now laid out, so its height is known. Correct the offset once so the decorated top sits at the
// requested inset, then let the next layout settle before notifying via afterScroll.
if (alignTopPosition != RecyclerView.NO_POSITION && !alignTopCorrected) {
val target = findViewByPosition(alignTopPosition)
if (target != null) {
alignTopCorrected = true
if (getDecoratedTop(target) != alignTopInset) {
val correctedOffset = (height - paddingBottom) - alignTopInset - getDecoratedMeasuredHeight(target)
super.scrollToPositionWithOffset(alignTopPosition, correctedOffset)
return
}
}
}
afterScroll?.invoke()
afterScroll = null
alignTopPosition = RecyclerView.NO_POSITION
}
companion object {
@@ -13,7 +13,8 @@ object AppCapabilities {
storage = storageCapable,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = true
)
}
}
@@ -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,10 +33,13 @@ 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.crypto.AttachmentSecretProvider;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.Scrubber;
@@ -43,11 +47,14 @@ import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.libsignal.net.ChatServiceException;
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
import org.signal.registration.RegistrationDependencies;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.preferences.EditProxyActivity;
import org.thoughtcrime.securesms.conversation.drafts.DraftBlobs;
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
@@ -58,6 +65,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
import org.thoughtcrime.securesms.glide.SignalGlideComponents;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
@@ -74,26 +82,30 @@ 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;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.util.RegistrationUtil;
import org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController;
import org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.AnalyzeDatabaseAlarmListener;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
@@ -103,20 +115,21 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BatterySnapshotTracker;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.signal.core.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.SupportEmailUtil;
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;
@@ -165,7 +178,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
SqlCipherLibraryLoader.load();
SignalDatabase.init(this,
DatabaseSecretProvider.getOrCreateDatabaseSecret(this),
AttachmentSecretProvider.getInstance(this).getOrCreateAttachmentSecret());
AttachmentSecretProvider.getInstance(this, AppAttachmentSecretStore.INSTANCE).getOrCreateAttachmentSecret());
Logger.setTarget(SqlCipherLogTarget.INSTANCE);
})
.addBlocking("signal-store", () -> SignalStore.init(this))
@@ -173,9 +186,9 @@ public class ApplicationContext extends Application implements AppForegroundObse
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("anr-detector", this::startAnrDetector)
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
@@ -224,7 +237,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
.addPostRender(MessageSendLogCleanupJob::enqueue)
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
@@ -253,6 +266,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
long startTime = System.currentTimeMillis();
Log.i(TAG, "App is now visible. Battery: " + DeviceProperties.getBatteryLevel(this) + "% (charging: " + DeviceProperties.isCharging(this) + ")");
BatterySnapshotTracker.emit(this, "foreground");
AppDependencies.getFrameRateTracker().start();
AppDependencies.getMegaphoneRepository().onAppForegrounded();
AppDependencies.getDeadlockDetector().start();
@@ -272,7 +287,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
checkFreeDiskSpace();
MemoryTracker.start();
BackupSubscriptionCheckJob.enqueueIfAble();
CheckKeyTransparencyJob.enqueueIfNecessary(true);
CheckKeyTransparencyJob.enqueueIfNecessary(true, false);
AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
@@ -293,6 +308,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
@Override
public void onBackground() {
Log.i(TAG, "App is no longer visible.");
BatterySnapshotTracker.emit(this, "background");
KeyCachingService.onAppBackgrounded(this);
AppDependencies.getMessageNotifier().clearVisibleThread();
AppDependencies.getFrameRateTracker().stop();
@@ -411,11 +427,25 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void initializeRegistrationDependencies() {
org.signal.registration.RegistrationDependencies.Companion.provide(
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
RegistrationDependencies.provide(
new RegistrationDependencies(
new AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new AppRegistrationStorageController(this),
Environment.IS_LINK_AND_SYNC_AVAILABLE,
null,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
return Unit.INSTANCE;
},
context -> {
context.startActivity(EditProxyActivity.intent(context));
return Unit.INSTANCE;
},
(context, subject) -> {
String body = SupportEmailUtil.generateSupportEmailBody(context, subject, null, null);
CommunicationActions.openEmail(context, SupportEmailUtil.getSupportEmailAddress(context), subject, body);
return Unit.INSTANCE;
}
)
);
}
@@ -439,14 +469,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.");
}
}
@@ -536,7 +590,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
@WorkerThread
private void initializeBlobProvider() {
BlobProvider.getInstance().initialize(this);
AppDependencies.getBlobs().initialize(this, DraftBlobs.INSTANCE::deleteOrphanedDraftFiles);
}
@WorkerThread
@@ -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;
@@ -1,12 +1,14 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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 org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Locale;
import java.util.Set;
@@ -14,14 +16,14 @@ 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,
long activeThreadId);
@Nullable RecipientId activeRecipientId);
void setSelectedConversations(@NonNull ConversationSet conversations);
void setActiveThreadId(long activeThreadId);
void setActiveRecipientId(@Nullable RecipientId activeRecipientId);
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
void updateTimestamp();
}
@@ -10,8 +10,8 @@ import androidx.activity.result.contract.ActivityResultContract
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
import org.signal.core.util.ServiceUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.ServiceUtil
/**
* Authentication using phone biometric (face, fingerprint recognition) or device lock (pattern, pin or passphrase).
@@ -23,45 +23,25 @@ public final class BlockUnblockDialog {
private BlockUnblockDialog() {}
public static void showReportSpamFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onReportSpam,
@Nullable Runnable onBlockAndReportSpam)
{
SimpleTask.run(lifecycle,
() -> buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam),
AlertDialog.Builder::show);
buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam).show();
}
public static void showBlockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock)
{
SimpleTask.run(lifecycle,
() -> buildBlockFor(context, recipient, onBlock, null),
AlertDialog.Builder::show);
}
public static void showBlockAndReportSpamFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onBlock,
@NonNull Runnable onBlockAndReportSpam)
{
SimpleTask.run(lifecycle,
() -> buildBlockFor(context, recipient, onBlock, onBlockAndReportSpam),
AlertDialog.Builder::show);
buildBlockFor(context, recipient, onBlock, null).show();
}
public static void showUnblockFor(@NonNull Context context,
@NonNull Lifecycle lifecycle,
@NonNull Recipient recipient,
@NonNull Runnable onUnblock)
{
SimpleTask.run(lifecycle,
() -> buildUnblockFor(context, recipient, onUnblock),
AlertDialog.Builder::show);
buildUnblockFor(context, recipient, onUnblock).show();
}
@WorkerThread
@@ -34,7 +34,6 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.io.IOException;
import java.lang.ref.WeakReference;
@@ -49,8 +48,7 @@ import java.util.function.Consumer;
*/
public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
implements SwipeRefreshLayout.OnRefreshListener,
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.ScrollCallback
ContactSelectionListFragment.OnContactSelectedListener
{
private static final String TAG = Log.tag(ContactSelectionActivity.class);
@@ -136,17 +134,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ServiceUtil.getInputMethodManager(this)
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
toolbar.clearFocus();
}
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
private final WeakReference<ContactSelectionActivity> activity;
@@ -1,16 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByPhoneNumberModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByUsernameModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsBannerModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsModel
import org.thoughtcrime.securesms.ContactSelectionListModels.InviteToSignalModel
import org.thoughtcrime.securesms.ContactSelectionListModels.MoreHeaderModel
import org.thoughtcrime.securesms.ContactSelectionListModels.NewGroupModel
import org.thoughtcrime.securesms.ContactSelectionListModels.RefreshContactsModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
@@ -23,152 +26,19 @@ class ContactSelectionListAdapter(
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item))
registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item))
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
ContactSelectionListModels.registerNewGroup(this, onClickCallbacks::onNewGroupClicked)
ContactSelectionListModels.registerInviteToSignal(this, onClickCallbacks::onInviteToSignalClicked)
ContactSelectionListModels.registerFindContacts(this, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerFindContactsBanner(this, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerRefreshContacts(this, onClickCallbacks::onRefreshContactsClicked)
ContactSelectionListModels.registerMoreHeader(this)
ContactSelectionListModels.registerEmpty(this)
ContactSelectionListModels.registerFindByUsername(this, onClickCallbacks::onFindByUsernameClicked)
ContactSelectionListModels.registerFindByPhoneNumber(this, onClickCallbacks::onFindByPhoneNumberClicked)
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return section.types.size
}
@@ -179,15 +49,15 @@ class ContactSelectionListAdapter(
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return when (ArbitraryRow.fromCode(arbitrary.type)) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
return when (ContactSelectionListModels.ArbitraryRow.fromCode(arbitrary.type)) {
ContactSelectionListModels.ArbitraryRow.NEW_GROUP -> NewGroupModel()
ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ContactSelectionListModels.ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
}
}
}
@@ -18,10 +18,8 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import org.signal.core.ui.logging.LoggingFragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
@@ -38,27 +36,24 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.jetbrains.annotations.NotNull;
import org.signal.core.ui.logging.LoggingFragment;
import org.signal.core.ui.permissions.Permissions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
@@ -71,18 +66,19 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRep
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -92,14 +88,13 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
@@ -126,22 +121,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
@@ -168,14 +158,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
setNewCallCallback((NewCallCallback) context);
}
if (getParentFragment() instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) getParentFragment());
}
if (context instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) context);
}
if (getParentFragment() instanceof OnContactSelectedListener) {
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
}
@@ -221,10 +203,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.newCallCallback = callback;
}
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
@@ -259,10 +237,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
@@ -304,138 +280,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
false,
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts
new ContactSearchPagedDataSourceRepository(requireContext(), requireContext().getString(R.string.note_to_self)),
fixedContacts,
false
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
@@ -452,25 +302,83 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onLoadFinished(size);
}
},
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
ContactSelectionListModels.composeEntries(
new ContactSelectionListModels.Callback() {
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
}
});
}
),
new ContactSearchAdapter.ClickCallbacks() {
@Override
public void onStoryClicked(@NotNull View view, ContactSearchData.@NotNull Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(ContactSearchData.@NotNull Expand expand) {
contactSearchViewModel.expandSection(expand.getSectionKey());
}
@Override
public void onChatTypeClicked(@NotNull View view, ContactSearchData.@NotNull ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
@Override
public void onUnknownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
null,
new CallButtonClickCallbacks()
);
return view;
@@ -595,32 +503,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void reset() {
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit && innerRecyclerView != null) {
if (resetPositionOnCommit) {
resetPositionOnCommit = false;
innerRecyclerView.scrollToPosition(0);
contactSearchViewModel.requestScrollPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(innerRecyclerView);
if (useFastScroller) {
contactSearchViewModel.setFastScrollEnabled(true);
} else {
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
}
if (headerActionView.isEnabled() && !hasQueryFilter()) {
headerActionView.setVisibility(View.VISIBLE);
} else {
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
}
@@ -701,7 +600,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
boolean isUnknown = contact instanceof ContactSearchKey.UnknownRecipientKey;
SelectedContact selectedContact = contact.requireSelectedContact();
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId())) {
boolean needsSelfCheck = !canSelectSelf && !selectedContact.hasUsername();
if (needsSelfCheck) {
lifecycleDisposable.add(contactChipViewModel.isSelf(selectedContact)
.subscribe(isSelf -> onItemClickResolved(contact, selectedContact, isUnknown, isSelf)));
} else {
onItemClickResolved(contact, selectedContact, isUnknown, false);
}
}
private void onItemClickResolved(ContactSearchKey contact, SelectedContact selectedContact, boolean isUnknown, boolean isSelf) {
if (isSelf) {
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
return;
}
@@ -790,8 +700,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
} else {
return false;
}
@@ -933,19 +843,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
!hasQuery)
{
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.NEW_GROUP.getCode());
}
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME.getCode());
}
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
if (includeChatTypes && !hasQuery) {
@@ -967,10 +877,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
HeaderAction sectionHeaderAction = (headerActionProvider != null && !hasQuery) ? headerActionProvider.getHeaderAction() : null;
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf ? new RecipientTable.IncludeSelfMode.IncludeWithRemap(getString(R.string.note_to_self)) : RecipientTable.IncludeSelfMode.Exclude.INSTANCE,
transportType,
!hideHeader,
sectionHeaderAction,
null,
!hideLetterHeaders(),
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
@@ -1017,13 +929,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.MORE_HEADING.getCode());
if (hasContactsPermissions(requireContext())) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS.getCode());
} else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS.getCode());
}
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
@@ -1113,15 +1025,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
}
}

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