Compare commits

...

189 Commits

Author SHA1 Message Date
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
1012 changed files with 43714 additions and 75084 deletions
+1
View File
@@ -1 +1,2 @@
*.ai binary
**/src/screenshotTest*/reference/**/*.png filter=lfs diff=lfs merge=lfs -text
+7 -6
View File
@@ -16,24 +16,25 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
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@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
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.
+8 -8
View File
@@ -16,21 +16,21 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
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.
@@ -42,12 +42,12 @@ jobs:
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
@@ -61,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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build image
run: |
cd reproducible-builds
docker build -t signal-android .
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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
@@ -1,2 +1,2 @@
java openjdk-17.0.2
java openjdk-21.0.2
uv latest
+16 -2
View File
@@ -16,6 +16,7 @@ plugins {
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 +28,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 = 1706
val canonicalVersionName = "8.15.2"
val canonicalVersionCode = 1716
val canonicalVersionName = "8.18.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -127,9 +128,16 @@ ktlint {
version.set("1.5.0")
}
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()
@@ -662,6 +670,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)
@@ -712,6 +721,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)
+423 -36814
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -8,6 +8,11 @@
-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*(**);
}
@@ -9,9 +9,11 @@ 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
/**
@@ -30,6 +32,13 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
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,76 @@
/*
* 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 org.hamcrest.Matchers.allOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
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
@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() {
startJobLoopForTests()
}
@Test
fun givenPermitAcquisitionFails_whenIDonateOnce_thenIExpectPaymentSetupErrorDialog() {
failDonationPermitAcquisition()
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION)
awaitDonationErrorDialog(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
@@ -12,13 +14,20 @@ 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.mockkObject
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.donations.InAppPaymentType
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
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.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -46,8 +55,24 @@ 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() {
startJobLoopForTests()
}
@After
fun tearDown() {
unmockkObject(DonationPermits)
}
@Test
fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() {
ActivityScenario.launch<CheckoutFlowActivity>(intent)
@@ -102,6 +127,43 @@ class CheckoutFlowActivityTest__RecurringDonations {
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()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDonationErrorDialog(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() {
AppDependencies.donationPermitsRepository.clearPermits()
mockkObject(DonationPermits)
every { DonationPermits.getDonationPermit() } returns RequestResult.Success("permit")
every { AppDependencies.donationsApi.createStripeSubscriptionPaymentMethod(any(), any(), any()) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(402))
val scenario = ActivityScenario.launch<CheckoutFlowActivity>(intent)
rxRule.defaultTestScheduler.triggerActions()
onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction)
onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click())
rxRule.defaultTestScheduler.triggerActions()
scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION)
awaitDonationErrorDialog(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(
@@ -0,0 +1,159 @@
/*
* 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 android.os.SystemClock
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.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 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 io.reactivex.rxjava3.schedulers.TestScheduler
import org.junit.rules.ExternalResource
import org.signal.donations.GooglePayApi
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.dependencies.AppDependencies
/**
* 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)
}
}
/**
* 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.
*/
private const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}"""
/**
* Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through
* [org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits].
*/
fun failDonationPermitAcquisition(statusCode: Int = 500) {
AppDependencies.donationPermitsRepository.clearPermits()
every { AppDependencies.donationsApi.createDonationPermits(any()) } returns RequestResult.NonSuccess(RestStatusCodeError(statusCode, emptyMap(), null))
}
/**
* 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.
*/
fun startJobLoopForTests() {
(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.
*/
fun ActivityScenario<CheckoutFlowActivity>.selectGooglePay(
composeRule: ComposeTestRule,
scheduler: TestScheduler,
inAppPaymentType: InAppPaymentType
) {
val deadline = SystemClock.uptimeMillis() + 10_000
var present = false
while (SystemClock.uptimeMillis() < deadline && !present) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
present = try {
composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty()
} catch (e: IllegalStateException) {
false
}
if (!present) {
Thread.sleep(100)
}
}
check(present) { "Google Pay button never appeared in the gateway selector." }
composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick()
pump(scheduler, iterations = 20)
onActivity { activity ->
(activity as GooglePayComponent).googlePayResultPublisher.onNext(
GooglePayComponent.GooglePayResult(
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPaymentType),
resultCode = Activity.RESULT_OK,
data = Intent()
)
)
}
}
/**
* Advances the Rx [scheduler] and pumps the main looper until the checkout error dialog with [titleResId] is
* displayed, bridging the real JobManager-backed setup job, the Rx observers, and dialog rendering.
*/
fun awaitDonationErrorDialog(scheduler: TestScheduler, titleResId: Int, timeoutMillis: Long = 15_000) {
val deadline = SystemClock.uptimeMillis() + timeoutMillis
var lastFailure: Throwable? = null
while (SystemClock.uptimeMillis() < deadline) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
try {
onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed()))
return
} catch (t: Throwable) {
lastFailure = t
}
Thread.sleep(100)
}
throw AssertionError("Donation error dialog ($titleResId) was not displayed within ${timeoutMillis}ms.", lastFailure)
}
private fun pump(scheduler: TestScheduler, iterations: Int, intervalMs: Long = 100) {
repeat(iterations) {
scheduler.triggerActions()
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
Thread.sleep(intervalMs)
}
}
@@ -20,22 +20,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 +67,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 +80,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 +107,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 +143,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 +178,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 +204,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))
@@ -259,7 +259,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 +278,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 +297,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 +317,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()
@@ -5,10 +5,10 @@
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.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
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 {
@@ -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)
}
}
@@ -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.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()
)
}
}
@@ -42,8 +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.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 {
@@ -142,26 +143,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
@@ -32,8 +32,8 @@ 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.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
@@ -348,11 +348,13 @@ class MainNavigationLaunchTest {
await(description = "no new ConversationFragment after Empty detail intent") {
recorder.createdArgs.size == baseline
}
// The user-visible signal that we're "back on the list" is the chat list fragment
// being attached, not just the VM saying CHATS.
awaitListFragment(launched, MainNavigationListLocation.CHATS)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
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}"
}
@@ -569,7 +571,7 @@ class MainNavigationLaunchTest {
}
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
return BlobProvider.getInstance()
return AppDependencies.blobs
.forData(bytes)
.withMimeType(mimeType)
.createForSingleSessionInMemory()
@@ -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,338 @@
/*
* 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.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
}
@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()
}
}
@@ -236,8 +236,9 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
}
@Test
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
fun skipsRedeliveryWithSameServerTimestamp() {
val timestamp = messageHelper.nextStartTime()
sendPniChangeNumber(timestamp = timestamp)
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
@@ -247,12 +248,41 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() + 1000
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()
@@ -37,6 +37,7 @@ class InAppPaymentsRule : ExternalResource() {
private fun initialisePutSubscription() {
AppDependencies.donationsApi.apply {
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
every { createSubscriber(any(), any()) } returns NetworkResult.Success(Unit)
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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)
@@ -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()
}
}
+1 -6
View File
@@ -438,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"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ object AppCapabilities {
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true,
usernameChangeSyncMessage = false // TODO(michelle): Turn on once all clients support it and add a migration
usernameChangeSyncMessage = true
)
}
}
@@ -39,6 +39,7 @@ 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;
@@ -46,14 +47,16 @@ 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.components.settings.app.AppSettingsActivity;
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.SQLiteDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -98,10 +101,11 @@ import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchRece
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;
@@ -114,14 +118,17 @@ import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.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.thoughtcrime.securesms.util.PlayServicesUtil;
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.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
@@ -171,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))
@@ -259,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();
@@ -299,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();
@@ -417,15 +427,24 @@ 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),
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(AppSettingsActivity.proxy(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;
}
)
);
@@ -571,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
@@ -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.ThreadWithRecipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Locale;
import java.util.Set;
@@ -18,10 +20,10 @@ public interface BindableConversationListItem extends Unbindable {
@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();
}
@@ -600,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;
}
@@ -5,17 +5,16 @@
package org.thoughtcrime.securesms
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
@@ -25,6 +24,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
@@ -59,10 +59,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
@@ -72,6 +75,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
@@ -85,6 +92,7 @@ import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.AppForegroundObserver
@@ -100,11 +108,14 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.callsNavEntries
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.calls.quality.CallQuality
import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
import org.thoughtcrime.securesms.chats.ConversationTransitionState
import org.thoughtcrime.securesms.chats.chatsNavEntries
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet
@@ -134,7 +145,6 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.ChatNavGraphState
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
@@ -143,7 +153,6 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
import org.thoughtcrime.securesms.main.MainMegaphoneState
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationRouter
@@ -156,13 +165,10 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarState
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.callNavGraphBuilder
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -184,7 +190,6 @@ import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SplashScreenUtil
import org.thoughtcrime.securesms.util.TopToastPopup
@@ -289,14 +294,6 @@ class MainActivity :
AppStartup.getInstance().onCriticalRenderEventStart()
enableEdgeToEdge(
navigationBarStyle = if (DynamicTheme.isDarkTheme(this)) {
SystemBarStyle.dark(0)
} else {
SystemBarStyle.light(0, 0)
}
)
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
@@ -498,27 +495,15 @@ class MainActivity :
}
}
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val convoTransitionState = ConversationTransitionState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation,
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
)
) {
chatNavGraphBuilder(chatNavGraphState)
LaunchedEffect(convoTransitionState) {
mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() }
}
val callsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
mainNavigationViewModel = mainNavigationViewModel,
currentListLocation = mainNavigationState.currentListLocation
) { it == MainNavigationListLocation.CALLS }
) {
callNavGraphBuilder(it)
LaunchedEffect(isSplitPane) {
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
}
val storiesNavHostController = rememberDetailNavHostController(
@@ -531,24 +516,30 @@ class MainActivity :
}
LaunchedEffect(Unit) {
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
fun navigateToLocation(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(location)
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
throw IllegalStateException("Navigation to ${mainNavigationState.currentListLocation} should be handled by ChatsBackStack.")
}
MainNavigationListLocation.CALLS -> {
throw IllegalStateException("Navigation to ${MainNavigationListLocation.CALLS} should be handled by CallsBackStack.")
}
MainNavigationListLocation.STORIES -> storiesNavHostController.navigateToDetailLocation(location)
}
}
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Conversation, is MainNavigationDetailLocation.Chats -> {
throw IllegalStateException("Navigation to $location should be handled by ChatsBackStack.")
}
is MainNavigationDetailLocation.CallLinkDetails, is MainNavigationDetailLocation.Calls -> {
throw IllegalStateException("Navigation to $location should be handled by CallsBackStack.")
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -560,7 +551,9 @@ class MainActivity :
}
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
scope.launch {
paneExpansionState.animateTo(listOnlyAnchor)
}
@@ -619,7 +612,7 @@ class MainActivity :
AppScaffold(
navigator = wrappedNavigator,
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
modifier = convoTransitionState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState,
contentWindowInsets = WindowInsets(),
snackbarHost = {
@@ -730,16 +723,28 @@ class MainActivity :
primaryContent = {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
DetailsScreenNavHost(
navHostController = chatsNavHostController,
contentLayoutData = contentLayoutData
NavDisplay(
backStack = mainNavigationViewModel.chatsBackStackEntries,
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
)
}
MainNavigationListLocation.CALLS -> {
DetailsScreenNavHost(
navHostController = callsNavHostController,
contentLayoutData = contentLayoutData
NavDisplay(
backStack = mainNavigationViewModel.callsBackStackEntries,
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider { callsNavEntries(isSplitPane) }
)
}
@@ -761,7 +766,7 @@ class MainActivity :
} else {
null
},
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
animatorFactory = if (mainNavigationState.currentListLocation.isChatsTab) {
noEnterTransitionFactory
} else {
AppScaffoldAnimationStateFactory.Default
@@ -829,6 +834,23 @@ class MainActivity :
SignalTheme.colors.colorSurface1
}
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val navBarColor = if (isSplitPane) backgroundColor.toArgb() else ContextCompat.getColor(context, CoreUiR.color.signal_colorSurface2)
LaunchedEffect(isDarkTheme, navBarColor) {
if (Build.VERSION.SDK_INT >= 26) {
enableEdgeToEdge(
navigationBarStyle = if (isDarkTheme) {
SystemBarStyle.dark(navBarColor)
} else {
SystemBarStyle.light(navBarColor, navBarColor)
}
)
} else {
enableEdgeToEdge()
}
}
val modifier = when {
isSplitPane -> {
Modifier
@@ -1059,8 +1081,16 @@ class MainActivity :
return
}
val extras = intent.extras
if (extras == null) {
Log.w(TAG, "Received a conversation intent with no extras. Ignoring it.")
intent.action = null
setIntent(intent)
return
}
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(extras)))
intent.action = null
setIntent(intent)
}
@@ -1161,7 +1191,7 @@ class MainActivity :
} else if (SignalStore.internal.useNewMediaActivity) {
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isCameraFirst = true,
isStory = destination == MainNavigationListLocation.STORIES
)
)
@@ -1175,24 +1205,7 @@ class MainActivity :
}
}
if (CameraXRemoteConfig.isSupported()) {
onGranted()
} else {
Permissions.with(this@MainActivity)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), CoreUiR.drawable.symbol_camera_24)
.withPermanentDenialDialog(
getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos),
null,
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos_videos,
supportFragmentManager
)
.onAllGranted(onGranted)
.onAnyDenied { Toast.makeText(this@MainActivity, R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() }
.execute()
}
onGranted()
}
inner class ToolbarCallback : MainToolbarCallback {
@@ -135,12 +135,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
startActivity(intent);
} else {
startActivity(intent);
finish();
}
}
}
@@ -226,11 +222,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this);
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
private Intent getEnterSignalPinIntent() {
@@ -1,107 +1,27 @@
package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.text.TextUtils;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.activity.ComponentActivity;
import androidx.lifecycle.ViewModelProvider;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.NewConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Rfc5724Uri;
import java.net.URISyntaxException;
public class SystemContactsEntrypointActivity extends Activity {
private static final String TAG = Log.tag(SystemContactsEntrypointActivity.class);
public class SystemContactsEntrypointActivity extends ComponentActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
startActivity(getNextIntent(getIntent()));
finish();
super.onCreate(savedInstanceState);
}
private Intent getNextIntent(Intent original) {
DestinationAndBody destination;
SystemContactsEntrypointViewModel viewModel = new ViewModelProvider(this).get(SystemContactsEntrypointViewModel.class);
if (original.getData() != null && "content".equals(original.getData().getScheme())) {
destination = getDestinationForSyncAdapter(original);
} else {
destination = getDestinationForView(original);
}
final Intent nextIntent;
if (TextUtils.isEmpty(destination.destination)) {
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
} else {
Recipient recipient = Recipient.external(destination.getDestination());
if (recipient != null) {
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
nextIntent = ConversationIntents.createBuilderSync(this, recipient.getId(), threadId)
.withDraftText(destination.getBody())
.build();
} else {
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
viewModel.getContactAction().observe(this, nextStep -> {
if (nextStep.getShowSpecifyRecipientToast()) {
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
}
}
return nextIntent;
}
startActivity(nextStep.getIntent());
finish();
});
private @NonNull DestinationAndBody getDestinationForView(Intent intent) {
try {
Rfc5724Uri smsUri = new Rfc5724Uri(intent.getData().toString());
return new DestinationAndBody(smsUri.getPath(), smsUri.getQueryParams().get("body"));
} catch (URISyntaxException e) {
Log.w(TAG, "unable to parse RFC5724 URI from intent", e);
return new DestinationAndBody("", "");
}
}
private @NonNull DestinationAndBody getDestinationForSyncAdapter(Intent intent) {
Cursor cursor = null;
try {
cursor = getContentResolver().query(intent.getData(), null, null, null, null);
if (cursor != null && cursor.moveToNext()) {
return new DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "");
}
return new DestinationAndBody("", "");
} finally {
if (cursor != null) cursor.close();
}
}
private static class DestinationAndBody {
private final String destination;
private final String body;
private DestinationAndBody(String destination, String body) {
this.destination = destination;
this.body = body;
}
public String getDestination() {
return destination;
}
public String getBody() {
return body;
}
viewModel.resolveNextStep(getIntent());
}
}
@@ -0,0 +1,100 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
import android.text.TextUtils
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.NewConversationActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Rfc5724Uri
import java.net.URISyntaxException
class SystemContactsEntrypointViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(SystemContactsEntrypointViewModel::class.java)
}
private val internalContactAction = MutableLiveData<ContactAction>()
val contactAction: LiveData<ContactAction> = internalContactAction
fun resolveNextStep(original: Intent) {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
getContactAction(AppDependencies.application, original)
}
internalContactAction.value = result
}
}
@WorkerThread
private fun getContactAction(context: Context, original: Intent): ContactAction {
val destination = if (original.data != null && "content" == original.data?.scheme) {
getDestinationForSyncAdapter(context, original)
} else {
getDestinationForView(original)
}
val destinationAddress = destination.destination
if (TextUtils.isEmpty(destinationAddress)) {
return ContactAction(NewConversationActivity.createIntent(context, destination.body), true)
}
val recipient = Recipient.external(destinationAddress!!)
if (recipient != null) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val nextIntent = ConversationIntents.createBuilderSync(context, recipient.id, threadId)
.withDraftText(destination.body)
.build()
return ContactAction(nextIntent, false)
}
return ContactAction(NewConversationActivity.createIntent(context, destination.body), true)
}
private fun getDestinationForView(intent: Intent): DestinationAndBody {
return try {
val smsUri = Rfc5724Uri(intent.data.toString())
DestinationAndBody(smsUri.path, smsUri.queryParams["body"])
} catch (e: URISyntaxException) {
Log.w(TAG, "unable to parse RFC5724 URI from intent", e)
DestinationAndBody("", "")
}
}
private fun getDestinationForSyncAdapter(context: Context, intent: Intent): DestinationAndBody {
context.contentResolver.query(intent.data!!, null, null, null, null).use { cursor ->
if (cursor != null && cursor.moveToNext()) {
return DestinationAndBody(cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.Data.DATA1)), "")
}
return DestinationAndBody("", "")
}
}
data class ContactAction(
val intent: Intent,
val showSpecifyRecipientToast: Boolean
)
private data class DestinationAndBody(
val destination: String?,
val body: String?
)
}
@@ -4,6 +4,7 @@ import android.net.Uri
import android.os.Parcel
import androidx.core.os.ParcelCompat
import org.signal.blurhash.BlurHash
import org.signal.core.models.database.AttachmentId
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.ParcelUtil
import org.thoughtcrime.securesms.audio.AudioHash
@@ -11,10 +11,11 @@ import androidx.annotation.Nullable;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.contentproviders.BlobProvider;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.notifications.v2.InChatNotificationSoundSuppressor;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.IOException;
@@ -88,9 +89,9 @@ public class AudioRecorder {
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
BlobProvider.BlobBuilder blobBuilder = BlobProvider.getInstance()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC);
BlobProvider.BlobBuilder blobBuilder = AppDependencies.getBlobs()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC);
recordingUri = blobBuilder.buildUriForDraftAttachment();
recordingUriFuture = blobBuilder.createForDraftAttachmentAsync(context);
@@ -33,7 +33,7 @@ public final class AudioWaveFormGenerator {
*/
@WorkerThread
public static @NonNull AudioFileInfo generateWaveForm(@NonNull Context context, @NonNull Uri uri) throws IOException {
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
try (MediaInput dataSource = DecryptableUriMediaInput.INSTANCE.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
@@ -7,9 +7,9 @@ import androidx.annotation.AnyThread
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.SingleSubject
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData
@@ -11,9 +11,9 @@ import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.models.media.Media
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.MediaUtil
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@@ -80,7 +80,7 @@ object AvatarRenderer {
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
val blob = BlobProvider.getInstance()
val blob = AppDependencies.blobs
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
.createForSingleSessionOnDisk(context)
@@ -124,7 +124,7 @@ object AvatarRenderer {
val bytes = outStream.toByteArray()
val inStream = ByteArrayInputStream(bytes)
val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
val uri = AppDependencies.blobs.forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
}
@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
@@ -39,15 +39,15 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
SignalExecutors.BOUNDED.execute {
val editedImageUri = imageEditorFragment.renderToSingleUseBlob()
val size = BlobProvider.getFileSize(editedImageUri) ?: 0
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
val size = AppDependencies.blobs.getFileSize(editedImageUri) ?: 0
val inputStream = AppDependencies.blobs.getStream(applicationContext, editedImageUri)
val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream)
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val database = SignalDatabase.avatarPicker
val newPhoto = photo.copy(uri = onDiskUri, size = size)
database.update(newPhoto)
BlobProvider.getInstance().delete(requireContext(), photo.uri)
AppDependencies.blobs.delete(requireContext(), photo.uri)
ThreadUtil.runOnMain {
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
@@ -1,13 +1,11 @@
package org.thoughtcrime.securesms.avatar.picker
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
@@ -30,12 +28,10 @@ import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
import org.thoughtcrime.securesms.components.ButtonStripItemView
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
/**
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
@@ -223,22 +219,8 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
@Suppress("DEPRECATION")
private fun openCameraCapture() {
if (CameraXRemoteConfig.isSupported()) {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
} else {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_allow_camera), CoreUiR.drawable.symbol_camera_24)
.withPermanentDenialDialog(getString(R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos, getParentFragmentManager())
.onAnyDenied { Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT).show() }
.execute()
}
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
@Suppress("DEPRECATION")
@@ -16,9 +16,9 @@ import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NameUtil
import org.whispersystems.signalservice.api.util.StreamDetails
@@ -36,7 +36,7 @@ class AvatarPickerRepository(context: Context) {
try {
val bytes = StreamUtil.readFully(details.stream)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AppDependencies.blobs.forData(bytes).createForSingleSessionInMemory(),
details.length,
Avatar.DatabaseId.DoNotPersist
)
@@ -56,7 +56,7 @@ class AvatarPickerRepository(context: Context) {
try {
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AppDependencies.blobs.forData(bytes).createForSingleSessionInMemory(),
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
Avatar.DatabaseId.DoNotPersist
)
@@ -19,11 +19,11 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -11,7 +11,7 @@ import org.signal.core.util.Conversions;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.signal.core.models.database.AttachmentId;
import org.thoughtcrime.securesms.backup.proto.Attachment;
import org.thoughtcrime.securesms.backup.proto.Avatar;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
@@ -6,7 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.crypto.KeyStoreHelper;
import org.signal.core.util.crypto.KeyStoreHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
/**
@@ -19,13 +19,13 @@ import org.signal.core.util.SetUtil;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.signal.core.models.database.AttachmentId;
import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
import org.signal.core.util.crypto.AttachmentSecret;
import org.signal.core.util.crypto.ClassicDecryptingPartInputStream;
import org.signal.core.util.crypto.ModernDecryptingPartInputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable;
import org.thoughtcrime.securesms.database.EmojiSearchTable;
@@ -24,8 +24,8 @@ import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.thoughtcrime.securesms.backup.proto.Sticker;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.signal.core.util.crypto.AttachmentSecret;
import org.signal.core.util.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.EmojiSearchTable;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
@@ -16,13 +16,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -34,6 +34,7 @@ import org.signal.core.models.backup.BackupId
import org.signal.core.models.backup.MediaName
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.CursorUtil
@@ -46,6 +47,7 @@ import org.signal.core.util.bytes
import org.signal.core.util.concurrent.LimitedWorker
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.crypto.AttachmentSecretProvider
import org.signal.core.util.decodeOrNull
import org.signal.core.util.forceForeignKeyConstraintsEnabled
import org.signal.core.util.fullWalCheckpoint
@@ -72,9 +74,7 @@ import org.signal.network.NetworkResult
import org.signal.network.StatusCodeErrorAction
import org.signal.network.api.SvrBApi
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.rest.toNetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -94,7 +94,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
@@ -141,7 +141,6 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.BackupMediaRestoreService
@@ -718,7 +717,7 @@ object BackupRepository {
SignalDatabase(
context = context,
databaseSecret = DatabaseSecretProvider.getOrCreateDatabaseSecret(context),
attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
attachmentSecret = AttachmentSecretProvider.getInstance(context, AppAttachmentSecretStore).getOrCreateAttachmentSecret(),
name = "$baseName.db"
)
}
@@ -1251,6 +1250,7 @@ object BackupRepository {
return ImportResult.Failure
}
SignalStore.backup.hasInvalidBackupVersion = false
val selfId: RecipientId
var transactionSuccessful = false
try {
@@ -1322,7 +1322,7 @@ object BackupRepository {
SignalStore.backup.mediaRootBackupKey = mediaRootBackupKey
// Add back self after clearing data
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
selfId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
@@ -1533,7 +1533,7 @@ object BackupRepository {
Log.d(TAG, "[import] Finished! ${eventTimer.stop().summary}")
stopwatch.stop(TAG)
return ImportResult.Success(backupTime = header.backupTimeMs)
return ImportResult.Success(backupTime = header.backupTimeMs, selfRecipientId = selfId)
}
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
@@ -2236,7 +2236,7 @@ object BackupRepository {
}
Log.i(TAG, "[remoteRestore] Downloading backup")
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = downloadBackupFile(tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[remoteRestore] Download successful")
else -> {
@@ -2311,19 +2311,23 @@ object BackupRepository {
forwardSecrecyToken = forwardSecrecyToken,
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[remoteRestore] Failed to import backup")
return RemoteRestoreResult.Failure
return when (result) {
is ImportResult.Failure -> {
Log.w(TAG, "[remoteRestore] Failed to import backup")
RemoteRestoreResult.Failure
}
is ImportResult.Success -> {
Log.i(TAG, "[remoteRestore] Restore successful")
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
RemoteRestoreResult.Success(result.selfRecipientId)
}
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[remoteRestore] Restore successful")
return RemoteRestoreResult.Success
}
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) {
suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult {
val context = AppDependencies.application
ArchiveRestoreProgress.onRestorePending()
@@ -2355,9 +2359,16 @@ object BackupRepository {
override fun shouldCancel() = cancellationSignal()
}
val cdn = response.cdn
val key = response.key
if (cdn == null || key == null) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Response has no archive location (error=${response.error}); nothing to download.")
return RemoteRestoreResult.Failure
}
Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup")
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) {
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) {
is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful")
else -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause())
@@ -2386,16 +2397,19 @@ object BackupRepository {
cancellationSignal = cancellationSignal
)
if (result == ImportResult.Failure) {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
return RemoteRestoreResult.Failure
return when (result) {
is ImportResult.Failure -> {
Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup")
RemoteRestoreResult.Failure
}
is ImportResult.Success -> {
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
RemoteRestoreResult.Success(result.selfRecipientId)
}
}
BackupMediaRestoreService.resetTimeout()
AppDependencies.jobManager.add(BackupRestoreMediaJob())
Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful")
return RemoteRestoreResult.Success
}
private fun buildDebugInfo(): ByteString {
@@ -2525,12 +2539,12 @@ data class StagedBackupKeyRotations(
)
sealed class ImportResult {
data class Success(val backupTime: Long) : ImportResult()
data class Success(val backupTime: Long, val selfRecipientId: RecipientId) : ImportResult()
data object Failure : ImportResult()
}
sealed interface RemoteRestoreResult {
data object Success : RemoteRestoreResult
data class Success(val selfRecipientId: RecipientId) : RemoteRestoreResult
data object NetworkError : RemoteRestoreResult
data object Canceled : RemoteRestoreResult
data object Failure : RemoteRestoreResult
@@ -2559,6 +2573,9 @@ enum class BackupMode {
val isLocalBackup: Boolean
get() = this == LOCAL
val isPlaintextExport: Boolean
get() = this == PLAINTEXT_EXPORT
}
/**
@@ -5,8 +5,8 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.model.MessageRecord
@@ -5,7 +5,7 @@
package org.thoughtcrime.securesms.backup.v2
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.signal.core.models.database.AttachmentId
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
/**
@@ -5,8 +5,8 @@
package org.thoughtcrime.securesms.backup.v2.database
import org.signal.core.models.database.AttachmentId
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.AttachmentTable
fun AttachmentTable.restoreWallpaperAttachment(attachment: Attachment): AttachmentId? {
@@ -167,7 +167,11 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
.where(
buildString {
append("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ")
append("($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds})")
if (exportState.backupMode.isPlaintextExport) {
append("$EXPIRES_IN == 0")
} else {
append("($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds})")
}
append(" AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")
}
)
@@ -42,6 +42,7 @@ import org.signal.archive.proto.Text
import org.signal.archive.proto.ThreadMergeChatUpdate
import org.signal.archive.proto.ViewOnceMessage
import org.signal.core.models.ServiceId
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.Hex
@@ -68,7 +69,6 @@ import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireString
import org.signal.core.util.toByteArray
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ExportOddities
@@ -647,15 +647,22 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
}
}
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null && builder.expireStartDate != null) {
val cutoffDuration = ChatItemArchiveExporter.EXPIRATION_CUTOFF.inWholeMilliseconds
val expiresAt = builder.expireStartDate!! + builder.expiresInMs!!
val threshold = if (exportState.backupMode.isLinkAndSync) backupStartTime else backupStartTime + cutoffDuration
if (expiresAt < threshold || (builder.expiresInMs!! <= cutoffDuration && !exportState.backupMode.isLinkAndSync)) {
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null) {
if (exportState.backupMode.isPlaintextExport) {
Log.w(TAG, ExportSkips.messageExpiresTooSoon(record.dateSent))
return null
}
if (builder.expireStartDate != null) {
val cutoffDuration = ChatItemArchiveExporter.EXPIRATION_CUTOFF.inWholeMilliseconds
val expiresAt = builder.expireStartDate!! + builder.expiresInMs!!
val threshold = if (exportState.backupMode.isLinkAndSync) backupStartTime else backupStartTime + cutoffDuration
if (expiresAt < threshold || (builder.expiresInMs!! <= cutoffDuration && !exportState.backupMode.isLinkAndSync)) {
Log.w(TAG, ExportSkips.messageExpiresTooSoon(record.dateSent))
return null
}
}
}
if (builder.expireStartDate != null && builder.expiresInMs == null) {
@@ -7,10 +7,10 @@ package org.thoughtcrime.securesms.backup.v2.importer
import androidx.core.content.contentValuesOf
import org.signal.archive.proto.Chat
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.database.restoreWallpaperAttachment
import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper
@@ -16,6 +16,7 @@ import org.signal.archive.stream.EncryptedBackupReader
import org.signal.core.models.backup.BackupId
import org.signal.core.models.backup.MediaName
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Stopwatch
import org.signal.core.util.StreamUtil
import org.signal.core.util.Util
@@ -23,7 +24,6 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.readFully
import org.signal.core.util.toJson
import org.signal.libsignal.crypto.Aes256Ctr32
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -12,11 +12,12 @@ import org.signal.archive.proto.AccountData
import org.signal.archive.proto.ChatStyle
import org.signal.archive.proto.Frame
import org.signal.archive.stream.BackupFrameEmitter
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.UuidUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.toByteArray
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -471,19 +472,19 @@ object AccountDataArchiveProcessor {
}
}
private fun org.thoughtcrime.securesms.mms.SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
private fun SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
return when (this) {
org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
org.thoughtcrime.securesms.mms.SentMediaQuality.HIGH -> AccountData.SentMediaQuality.HIGH
SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
SentMediaQuality.HIGH -> AccountData.SentMediaQuality.HIGH
}
}
private fun AccountData.SentMediaQuality?.toLocalSentMediaQuality(): org.thoughtcrime.securesms.mms.SentMediaQuality {
private fun AccountData.SentMediaQuality?.toLocalSentMediaQuality(): SentMediaQuality {
return when (this) {
AccountData.SentMediaQuality.HIGH -> org.thoughtcrime.securesms.mms.SentMediaQuality.HIGH
AccountData.SentMediaQuality.STANDARD -> org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD
AccountData.SentMediaQuality.UNKNOWN_QUALITY -> org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD
null -> org.thoughtcrime.securesms.mms.SentMediaQuality.STANDARD
AccountData.SentMediaQuality.HIGH -> SentMediaQuality.HIGH
AccountData.SentMediaQuality.STANDARD -> SentMediaQuality.STANDARD
AccountData.SentMediaQuality.UNKNOWN_QUALITY -> SentMediaQuality.STANDARD
null -> SentMediaQuality.STANDARD
}
}
@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
/**
* Bottom sheet shown when confirming your recovery key after saving to password manager
*/
@Composable
fun ConfirmRecoveryKeySheet(
onConfirm: () -> Unit = {},
onSeeAgain: () -> Unit = {},
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.horizontalGutters(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.backup_confirm_80),
tint = Color.Unspecified,
contentDescription = null,
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_that_your_recovery),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.size(60.dp))
Buttons.LargeTonal(onClick = onConfirm) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_recovery))
}
TextButton(
onClick = onSeeAgain,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again))
}
}
}
@DayNightPreviews
@Composable
private fun ConfirmRecoveryKeyPreview() {
Previews.BottomSheetContentPreview {
ConfirmRecoveryKeySheet(
onConfirm = {},
onSeeAgain = {},
modifier = Modifier.fillMaxSize()
)
}
}
@@ -68,7 +68,8 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
private val viewModel: MessageBackupsFlowViewModel by viewModel {
MessageBackupsFlowViewModel(
initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java),
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext())
)
}
@@ -155,6 +156,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
val context = LocalContext.current
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.backupKeySaveState,
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
onNavigationClick = viewModel::goToPreviousStage,
mode = remember {
MessageBackupsKeyRecordMode.Passkey(
onSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onSaveManually = viewModel::goToRecordManually,
onSaveSuccessful = viewModel::goToNextStage
)
},
onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) },
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveStateCleared = viewModel::onBackupKeySaveStateCleared,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) },
notifyKeyIsSameAsOnDeviceBackupKey = SignalStore.backup.newLocalBackupsEnabled
)
}
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD_MANUALLY.name) {
val context = LocalContext.current
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.backupKeySaveState,
@@ -53,6 +53,7 @@ import kotlin.time.Duration.Companion.seconds
class MessageBackupsFlowViewModel(
private val initialTierSelection: MessageBackupTier?,
googlePlayApiAvailability: Int,
private val isCredentialManagerSupported: Boolean,
startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
) : ViewModel(), BackupKeyCredentialManagerHandler {
@@ -238,8 +239,9 @@ class MessageBackupsFlowViewModel(
when (it.stage) {
MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL")
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
MessageBackupsStage.BACKUP_KEY_VERIFY -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT)
@@ -262,7 +264,8 @@ class MessageBackupsFlowViewModel(
MessageBackupsStage.EDUCATION -> MessageBackupsStage.CANCEL
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
@@ -277,6 +280,12 @@ class MessageBackupsFlowViewModel(
}
}
fun goToRecordManually() {
internalStateFlow.update {
it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY)
}
}
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
internalStateFlow.update {
it.copy(
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
@@ -28,6 +29,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -46,6 +48,7 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.signal.core.ui.R as CoreUiR
enum class MessageBackupsKeyEducationScreenMode {
@@ -80,6 +83,14 @@ fun MessageBackupsKeyEducationScreen(
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.DEFAULT
) {
val scrollState = rememberScrollState()
val context = LocalContext.current
val biometrics = rememberBiometricsAuthentication(
promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key),
onAuthenticationFailed = {
Toast.makeText(context, R.string.RemoteBackupsSettingsFragment__authentication_required, Toast.LENGTH_SHORT).show()
}
)
Scaffolds.Settings(
title = "",
@@ -139,7 +150,7 @@ fun MessageBackupsKeyEducationScreen(
.padding(top = 16.dp, bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick,
onClick = { biometrics.withBiometricsAuthentication(onNextClick) },
modifier = Modifier.align(Alignment.Center)
) {
Text(
@@ -6,14 +6,22 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.annotation.UiContext
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -22,6 +30,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState
@@ -34,9 +43,16 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
@@ -48,6 +64,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
@@ -85,6 +102,11 @@ sealed interface MessageBackupsKeyRecordMode {
val isOptimizedStorageEnabled: Boolean,
val canRotateKey: Boolean
) : MessageBackupsKeyRecordMode
data class Passkey(
val onSaveToPasswordManager: () -> Unit,
val onSaveManually: () -> Unit,
val onSaveSuccessful: () -> Unit
) : MessageBackupsKeyRecordMode
}
/**
@@ -136,18 +158,24 @@ fun MessageBackupsKeyRecordScreen(
onRequestSaveToPasswordManager: () -> Unit = {},
onConfirmSaveToPasswordManager: () -> Unit = {},
onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {},
onSaveStateCleared: () -> Unit = {},
onGoToPasswordManagerSettingsClick: () -> Unit = {},
mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false
) {
TemporaryScreenshotSecurity.bind()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val backupKeyString = remember(backupKey) {
backupKey.chunked(4).joinToString(" ")
}
if (mode is MessageBackupsKeyRecordMode.Next) {
val showAsPasskey = mode is MessageBackupsKeyRecordMode.Passkey
var showExpandedPasskey by remember { mutableStateOf(false) }
if (mode is MessageBackupsKeyRecordMode.Next || mode is MessageBackupsKeyRecordMode.Passkey) {
RecordScreenBackHandler()
}
@@ -183,6 +211,41 @@ fun MessageBackupsKeyRecordScreen(
}
}
var displayKeyVerificationError by remember { mutableStateOf(false) }
if (displayKeyVerificationError) {
ConfirmationFailureDialog(mode) {
displayKeyVerificationError = false
}
}
var displayConfirmKey by remember { mutableStateOf(false) }
if (displayConfirmKey) {
val context = LocalContext.current
val credentialId = stringResource(R.string.MessageBackupsKeyRecordScreen__backup_key_password_manager_id)
val successMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_confirmed)
ModalBottomSheet(
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = SignalTheme.colors.colorSurface1,
onDismissRequest = { displayConfirmKey = false }
) {
ConfirmRecoveryKeySheet(
onConfirm = {
coroutineScope.launch {
val retrieved = getKeyFromCredentialManager(context, credentialId)
if (retrieved == backupKey) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveSuccessful()
} else {
displayKeyVerificationError = true
}
}
displayConfirmKey = false
},
onSeeAgain = { displayConfirmKey = false }
)
}
}
Scaffolds.Settings(
title = "",
navigationIcon = SignalIcons.ArrowStart.imageVector,
@@ -216,7 +279,11 @@ fun MessageBackupsKeyRecordScreen(
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
text = if (showAsPasskey) {
stringResource(R.string.MessageBackupsKeyRecordScreen__save_your_recovery_key)
} else {
stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key)
},
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
@@ -225,6 +292,8 @@ fun MessageBackupsKeyRecordScreen(
item {
val text = if (notifyKeyIsSameAsOnDeviceBackupKey) {
stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_the_same_as_your_on_device_recovery_key)
} else if (showAsPasskey) {
stringResource(R.string.MessageBackupsKeyRecordScreen__your_recovery_key)
} else {
stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover)
}
@@ -239,47 +308,113 @@ fun MessageBackupsKeyRecordScreen(
}
item {
Box(
modifier = Modifier
.padding(top = 24.dp, bottom = 16.dp)
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
AnimatedContent(
targetState = showAsPasskey && !showExpandedPasskey,
transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform(clip = false) },
label = "passkey",
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
) { isCollapsed ->
if (isCollapsed) {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(50.dp)
)
) {
Text(
text = backupKeyString,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
),
modifier = Modifier
.padding(10.dp)
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
brush = Brush.horizontalGradient(
0f to Color.Black,
0.75f to Color.Transparent
),
blendMode = BlendMode.DstIn
)
}
)
)
}
}
item {
Buttons.Small(
onClick = {
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
displayRecoveryKeyCopyWarning = true
} else {
onCopyToClipboardClick(backupKeyString)
Row(
modifier = Modifier
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(50.dp)
)
.padding(horizontal = 12.dp)
.clickable(onClick = { showExpandedPasskey = true }),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_tap_20),
contentDescription = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 4.dp, end = 12.dp)
)
}
}
} else {
Box(
modifier = Modifier
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = MonoTypeface.fontFamily()
)
)
}
}
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
}
if (AndroidCredentialRepository.isCredentialManagerSupported) {
if (!showAsPasskey || showExpandedPasskey) {
item {
Buttons.Small(
onClick = {
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
displayRecoveryKeyCopyWarning = true
} else {
onCopyToClipboardClick(backupKeyString)
}
}
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
}
}
if (!showAsPasskey && AndroidCredentialRepository.isCredentialManagerSupported(context)) {
item {
Buttons.Small(
onClick = { onRequestSaveToPasswordManager() }
@@ -300,6 +435,10 @@ fun MessageBackupsKeyRecordScreen(
is MessageBackupsKeyRecordMode.CreateNewKey -> {
CreateNewKeyButton(mode)
}
is MessageBackupsKeyRecordMode.Passkey -> {
SaveButtons(mode)
}
}
}
@@ -326,7 +465,12 @@ fun MessageBackupsKeyRecordScreen(
is BackupKeySaveState.Success -> {
val snackbarMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager_success)
LaunchedEffect(keySaveState) {
snackbarHostState.showSnackbar(snackbarMessage)
if (showAsPasskey) {
displayConfirmKey = true
} else {
snackbarHostState.showSnackbar(snackbarMessage)
}
onSaveStateCleared()
}
}
@@ -343,6 +487,24 @@ fun MessageBackupsKeyRecordScreen(
}
}
@Composable
private fun SaveButtons(mode: MessageBackupsKeyRecordMode.Passkey) {
Buttons.LargeTonal(
onClick = mode.onSaveToPasswordManager
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager))
}
TextButton(
onClick = mode.onSaveManually,
modifier = Modifier
.padding(vertical = 24.dp)
.horizontalGutters()
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually))
}
}
@Composable
private fun NextButton(onNextClick: () -> Unit) {
Box(
@@ -578,6 +740,26 @@ private fun KeyLimitExceededDialog(
)
}
@Composable
private fun ConfirmationFailureDialog(mode: MessageBackupsKeyRecordMode, onDismiss: () -> Unit) {
Dialogs.AdvancedAlertDialog(
title = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error),
body = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error_body),
positive = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager),
onPositive = {
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveToPasswordManager()
onDismiss()
},
neutral = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually),
onNeutral = {
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveManually()
onDismiss()
},
negative = stringResource(android.R.string.cancel),
onNegative = onDismiss
)
}
private suspend fun saveKeyToCredentialManager(
@UiContext activityContext: Context,
backupKey: String
@@ -589,6 +771,13 @@ private suspend fun saveKeyToCredentialManager(
)
}
private suspend fun getKeyFromCredentialManager(
@UiContext activityContext: Context,
id: String
): String? {
return AndroidCredentialRepository.getCredential(activityContext, id)
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
@@ -621,6 +810,23 @@ private fun MessageBackupsKeyRecordScreenSameAsOnDeviceKeyPreview() {
}
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeySaveScreenPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0",
keySaveState = null,
canOpenPasswordManagerSettings = true,
mode = MessageBackupsKeyRecordMode.Passkey(
onSaveToPasswordManager = {},
onSaveManually = {},
onSaveSuccessful = {}
)
)
}
}
@DayNightPreviews
@Composable
private fun SaveKeyConfirmationDialogPreview() {
@@ -15,6 +15,7 @@ enum class MessageBackupsStage(
EDUCATION(route = Route.EDUCATION),
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
BACKUP_KEY_RECORD_MANUALLY(route = Route.BACKUP_KEY_RECORD_MANUALLY),
BACKUP_KEY_VERIFY(route = Route.BACKUP_KEY_VERIFY),
TYPE_SELECTION(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
@@ -32,6 +33,7 @@ enum class MessageBackupsStage(
EDUCATION,
BACKUP_KEY_EDUCATION,
BACKUP_KEY_RECORD,
BACKUP_KEY_RECORD_MANUALLY,
BACKUP_KEY_VERIFY,
TYPE_SELECTION;
@@ -61,7 +61,7 @@ class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
// Matches existing behavior: show a generic "authentication required" toast.
Toast.makeText(
context,
R.string.RemoteBackupsSettingsFragment__authenticatino_required,
R.string.RemoteBackupsSettingsFragment__authentication_required,
Toast.LENGTH_SHORT
).show()
}
@@ -7,8 +7,8 @@ package org.thoughtcrime.securesms.backup.v2.util
import org.signal.archive.proto.ChatStyle
import org.signal.archive.proto.FilePointer
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.conversation.colors.ChatColors
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -51,6 +52,11 @@ private fun Banner(contentPadding: PaddingValues) {
importance = Importance.ERROR,
actions = listOf(
Action(R.string.UnauthorizedReminder_reregister_action) {
if (SignalStore.misc.isOldDeviceTransferLocked) {
SignalStore.misc.isOldDeviceTransferLocked = false
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork()
}
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
context.startActivity(registrationIntent)
}
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -61,6 +62,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment()
MessageBackupsFlowViewModel(
initialTierSelection = MessageBackupTier.PAID,
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext()),
startScreen = MessageBackupsStage.TYPE_SELECTION
)
}
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
/**
* Controls the navigation stack used by the calls screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class CallsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "calls_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
val activeCallId: CallLogRow.Id?
get() = entries.asReversed().firstNotNullOfOrNull { location ->
when (location) {
is MainNavigationDetailLocation.Calls -> location.controllerKey
is MainNavigationDetailLocation.CallLinkDetails -> location.controllerKey
else -> null
}
}
/**
* Pushes an entry onto the stack.
*/
override fun push(location: MainNavigationDetailLocation) {
when {
location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit
location.isContentRoot -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
fun EntryProviderScope<NavKey>.callsNavEntries(isSplitPane: Boolean) {
entry<MainNavigationDetailLocation.Empty> {
NoCallSelectedEntry()
}
entry<MainNavigationDetailLocation.CallLinkDetails>(
metadata = if (isSplitPane) TransitionSpecs.None.metadata else emptyMap()
) { route ->
CallLinkDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName> { route ->
EditCallLinkNameEntry(route)
}
}
@Composable
private fun NoCallSelectedEntry() {
EmptyDetailScreen()
}
@Composable
private fun CallLinkDetailsEntry(route: MainNavigationDetailLocation.CallLinkDetails) {
informNavigatorWeAreReady()
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
@Composable
private fun EditCallLinkNameEntry(route: MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName) {
informNavigatorWeAreReady()
EditCallLinkNameScreen(
roomId = route.callLinkRoomId,
initialName = route.currentName
)
}
@Composable
private fun informNavigatorWeAreReady() {
val navigator = LocalActivity.current as? MainNavigator.NavigatorProvider
LaunchedEffect(navigator) {
navigator?.onFirstRender()
}
}
@@ -89,7 +89,8 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Composable
fun EditCallLinkNameScreen(
roomId: CallLinkRoomId
roomId: CallLinkRoomId,
initialName: String
) {
val viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
@@ -99,7 +100,7 @@ fun EditCallLinkNameScreen(
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
EditCallLinkNameScreen(
initialNameValue = viewModel.nameSnapshot,
initialNameValue = initialName,
onSaveClick = {
lifecycleScope.launch {
viewModel.setName(it)
@@ -1,71 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
class CallLinkDetailsActivity : FragmentActivity() {
companion object {
private const val ARG_ROOM_ID = "room.id"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(ARG_ROOM_ID, callLinkRoomId)
}
}
private val roomId: CallLinkRoomId
get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!!
private val viewModel: CallLinkDetailsViewModel by viewModel {
CallLinkDetailsViewModel(roomId)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
SignalTheme {
CallLinkDetailsScreen(
roomId = roomId,
viewModel = viewModel,
router = remember { Router() }
)
}
}
}
private inner class Router : MainNavigationCallDetailRouter {
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) {
when (location) {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
}
}
override fun exitDetailLocation() = finishAfterTransition()
}
}
@@ -113,7 +113,12 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
router.goToCallDetail(
MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
currentName = viewModel.nameSnapshot
)
)
}
override fun onShareClicked() {
@@ -221,7 +226,7 @@ fun CallLinkDetailsScreen(
)
}
if (state.callLink.credentials?.adminPassBytes != null) {
if (state.callLink.canModify) {
item {
Rows.TextRow(
text = stringResource(
@@ -273,13 +278,15 @@ fun CallLinkDetailsScreen(
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = SignalIcons.Trash.imageVector,
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
if (state.callLink.canModify) {
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = SignalIcons.Trash.imageVector,
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
}
}
}
@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
@@ -194,10 +193,4 @@ class CallLinkDetailsViewModel(
private fun toastCouldNotDeleteCallLink() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) }
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
}
}
}
@@ -9,13 +9,13 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.signal.core.ui.R as CoreUiR
@@ -122,11 +122,10 @@ class CallLogContextMenu(
iconRes = CoreUiR.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = when {
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
when {
peer.isCallLink -> callbacks.goToCallLinkDetails(peer.requireCallLinkRoomId())
else -> fragment.startActivity(ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds))
}
fragment.startActivity(intent)
}
}
@@ -154,6 +153,7 @@ class CallLogContextMenu(
interface Callbacks {
fun startSelection(call: CallLogRow)
fun goToCallLinkDetails(roomId: CallLinkRoomId)
fun deleteCall(call: CallLogRow)
}
}
@@ -32,7 +32,6 @@ import org.signal.core.util.orNull
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -58,6 +57,7 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
@@ -242,9 +242,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
MainToolbarViewModel.Event.Search.Close -> {
viewModel.setSearchQuery("")
}
MainToolbarViewModel.Event.Search.Open -> {
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
}
is MainToolbarViewModel.Event.Search.Query -> {
viewModel.setSearchQuery(it.query.trim())
}
@@ -325,7 +327,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
startActivity(intent)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
goToCallLinkDetails(callLogRow.peer.requireCallLinkRoomId())
}
}
@@ -375,6 +377,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
}
}
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
@@ -386,6 +389,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
viewModel.toggleSelected(call.id)
}
override fun goToCallLinkDetails(roomId: CallLinkRoomId) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(roomId))
}
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Controls the navigation stack used by the chats screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "chats_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
val activeRecipientId: RecipientId?
get() = entries.asReversed().firstNotNullOfOrNull {
when (it) {
is MainNavigationDetailLocation.Conversation -> it.conversationArgs.recipientId
is MainNavigationDetailLocation.Chats -> it.controllerKey
else -> null
}
}
/**
* Pushes an entry onto the stack.
*/
override fun push(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
is MainNavigationDetailLocation.Conversation -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
}
@@ -0,0 +1,161 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
import org.thoughtcrime.securesms.compose.FragmentBackHandler
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
fun EntryProviderScope<NavKey>.chatsNavEntries(
transitionState: ConversationTransitionState
) {
entry<MainNavigationDetailLocation.Empty> {
NoConvoSelectedEntry()
}
entry<MainNavigationDetailLocation.Conversation>(
// disable slide animation - it's unnecessary in split pane mode and is handled by ConversationLoadingMask for single pane mode.
metadata = TransitionSpecs.None.metadata
) { route ->
ConversationEntry(route, transitionState)
}
entry<MainNavigationDetailLocation.Chats.MessageDetails> { route ->
MessageDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Chats.ConversationSettings> { route ->
ConversationSettingsEntry(route)
}
}
@Composable
private fun NoConvoSelectedEntry() {
EmptyDetailScreen()
}
@Composable
private fun ConversationEntry(
route: MainNavigationDetailLocation.Conversation,
transitionState: ConversationTransitionState
) {
val context = LocalContext.current
val navigatorProvider = context as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
val arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) {
"Handed null Conversation intent arguments."
}
val fragmentContentReady = remember { MutableStateFlow(false) }
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
ConversationLoadingMask(
transitionState = transitionState,
contentReady = fragmentContentReady,
onFirstRender = { navigatorProvider?.onFirstRender() }
) { modifier ->
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = arguments,
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
) { fragment ->
backPressedState.attach(fragment)
fragment.viewLifecycleOwner.lifecycleScope.launch {
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
fragment.didFirstFrameRender.collectLatest { fragmentContentReady.value = it }
}
}
}
}
}
@Composable
private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.MessageDetails) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
LaunchedEffect(Unit) {
navigatorProvider?.onFirstRender()
}
AndroidFragment(
clazz = MessageDetailsFragment::class.java,
fragmentState = fragmentState,
arguments = MessageDetailsFragment.args(route.recipientId, route.messageId),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
)
}
@Composable
private fun ConversationSettingsEntry(route: MainNavigationDetailLocation.Chats.ConversationSettings) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
val arguments: Bundle? by produceState(null, route.recipientId) {
value = ConversationSettingsNavHostFragment.createArgs(route.recipientId)
}
LaunchedEffect(Unit) {
navigatorProvider?.onFirstRender()
}
arguments?.let { args ->
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
AndroidFragment(
clazz = ConversationSettingsNavHostFragment::class.java,
fragmentState = fragmentState,
arguments = args,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
) { fragment ->
backPressedState.attach(fragment)
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import kotlin.time.Duration.Companion.seconds
/**
* Wraps [content] with an animation that crossfades a snapshotted chat list bitmap over the conversation fragment while it loads its first frame.
*
* @param contentReady emits when the fragment's first frame has been rendered.
* @param onFirstRender signals that this composable has content ready to display, so the parent activity can proceed with its first draw.
* @param content will be animated in as the overlay fades out.
*/
@Composable
fun ConversationLoadingMask(
transitionState: ConversationTransitionState,
contentReady: StateFlow<Boolean>,
onFirstRender: () -> Unit,
content: @Composable (chatModifier: Modifier) -> Unit
) {
// it can take a long time to load content, so we use a "fake" chat list image to delay displaying the fragment
// and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation.
var shouldDisplayFragment by remember {
val hasBitmap = transitionState.chatBitmap != null
mutableStateOf(!hasBitmap)
}
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
val bitmap = transitionState.chatBitmap
val fakeChatListAnimationState = transition.fakeChatListAnimationState()
val chatAnimationState = transition.chatAnimationState(hasFake = bitmap != null)
LaunchedEffect(transition.currentState, transition.isRunning) {
if (transition.currentState && !transition.isRunning) {
transitionState.clearBitmap()
}
}
LaunchedEffect(shouldDisplayFragment) {
onFirstRender()
}
LaunchedEffect(contentReady) {
if (!shouldDisplayFragment) {
withTimeoutOrNull(5.seconds) {
contentReady.first { it }
}
shouldDisplayFragment = true
}
}
val chatModifier = Modifier.graphicsLayer {
with(chatAnimationState) { applyChildValues() }
}
Box(modifier = Modifier.fillMaxSize()) {
content(chatModifier)
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier
.graphicsLayer {
with(fakeChatListAnimationState) { applyChildValues() }
}
.fillMaxSize()
)
}
}
}
@Composable
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
return remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
@Composable
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
return if (!hasFake) {
remember {
AppScaffoldAnimationState(
offset = mutableStateOf(0.dp),
alpha = alpha
)
}
} else {
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
/**
* Allows the setting of a "fake" bitmap driven by a graphics layer to coordinate delayed animations
* in lieu of proper support for postponing enter transitions.
*/
@Stable
class ConversationTransitionState private constructor(
val isSplitPane: Boolean,
val graphicsLayer: GraphicsLayer
) {
companion object {
@Composable
fun remember(isSplitPane: Boolean): ConversationTransitionState {
val graphicsLayer = rememberGraphicsLayer()
return remember(isSplitPane) {
ConversationTransitionState(isSplitPane, graphicsLayer)
}
}
}
var chatBitmap: ImageBitmap? by mutableStateOf(null)
private set
private var hasWrittenToGraphicsLayer: Boolean by mutableStateOf(false)
suspend fun writeGraphicsLayerToBitmap() {
// toImageBitmap() uses LayerSnapshot which has format compatibility issues on Android 7 and below
if (Build.VERSION.SDK_INT >= 26 && !isSplitPane && hasWrittenToGraphicsLayer) {
chatBitmap = graphicsLayer.toImageBitmap()
}
}
fun writeContentToGraphicsLayer(): Modifier {
if (isSplitPane) return Modifier
return Modifier.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
hasWrittenToGraphicsLayer = true
}
drawLayer(graphicsLayer)
}
}
fun clearBitmap() {
chatBitmap = null
}
}
@@ -20,8 +20,8 @@ data class ViewColorSet(
) : Parcelable {
companion object {
val PRIMARY = ViewColorSet(
foreground = ViewColor.ColorResource(CoreUiR.color.signal_colorOnPrimary),
background = ViewColor.ColorResource(CoreUiR.color.signal_colorPrimary)
foreground = ViewColor.ColorResource(CoreUiR.color.signal_light_colorOnPrimary),
background = ViewColor.ColorResource(CoreUiR.color.signal_light_colorPrimary)
)
fun forCustomColor(@ColorInt customColor: Int): ViewColorSet {
@@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.audio.AudioWaveForms;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobs.AttachmentBackfill;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
@@ -82,9 +83,11 @@ public final class AudioView extends FrameLayout {
private boolean isPlaying;
private long durationMillis;
private AudioSlide audioSlide;
private boolean showControls;
private Callbacks callbacks;
private Disposable disposable = Disposable.disposed();
private Disposable disposable = Disposable.disposed();
private Disposable awaitingDisposable = Disposable.disposed();
private final Observer<VoiceNotePlaybackState> playbackStateObserver = this::onPlaybackState;
@@ -159,6 +162,12 @@ public final class AudioView extends FrameLayout {
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
awaitingDisposable = AttachmentBackfill.awaitingChanges()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {
if (audioSlide != null) presentTransferControls(audioSlide, showControls);
}, t -> Log.w(TAG, "Error observing backfill awaiting state.", t));
}
@Override
@@ -166,6 +175,7 @@ public final class AudioView extends FrameLayout {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
disposable.dispose();
awaitingDisposable.dispose();
}
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
@@ -197,27 +207,8 @@ public final class AudioView extends FrameLayout {
}
}
if (showControls && audio.isPendingDownload()) {
controlToggle.displayQuick(downloadContainer);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress != null) {
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
circleProgress.setVisibility(View.GONE);
}
} else if (showControls && audio.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(progressAndPlay);
seekBar.setEnabled(false);
showPlayButton();
if (circleProgress != null) {
circleProgress.setVisibility(View.VISIBLE);
circleProgress.spin();
}
} else {
seekBar.setEnabled(true);
if (circleProgress != null && circleProgress.isSpinning()) circleProgress.stopSpinning();
showPlayButton();
}
this.showControls = showControls;
presentTransferControls(audio, showControls);
if (seekBar instanceof WaveFormSeekBarView) {
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
@@ -263,6 +254,38 @@ public final class AudioView extends FrameLayout {
}
}
private void presentTransferControls(@NonNull AudioSlide audio, boolean showControls) {
DatabaseAttachment dbAttachment = audio.asAttachment() instanceof DatabaseAttachment ? (DatabaseAttachment) audio.asAttachment() : null;
if (dbAttachment != null && dbAttachment.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId)) {
AttachmentBackfill.onAttachmentTerminal(dbAttachment.attachmentId, dbAttachment.mmsId);
}
boolean awaitingBackfill = dbAttachment != null && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId);
if (showControls && audio.isPendingDownload() && !awaitingBackfill) {
controlToggle.displayQuick(downloadContainer);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress != null) {
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
circleProgress.setVisibility(View.GONE);
}
} else if (showControls && (audio.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED || awaitingBackfill)) {
controlToggle.displayQuick(progressAndPlay);
seekBar.setEnabled(false);
showPlayButton();
if (circleProgress != null) {
circleProgress.setVisibility(View.VISIBLE);
if (!circleProgress.isSpinning()) circleProgress.spin();
}
} else {
seekBar.setEnabled(true);
if (circleProgress != null && circleProgress.isSpinning()) circleProgress.stopSpinning();
showPlayButton();
}
}
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
this.downloadListener = listener;
}
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobs.AttachmentBackfill;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
@@ -36,6 +37,9 @@ import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.Collections;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
public class DocumentView extends FrameLayout {
private static final String TAG = Log.tag(DocumentView.class);
@@ -55,6 +59,9 @@ public class DocumentView extends FrameLayout {
private @Nullable SlidesClickedListener cancelTransferClickListener;
private @Nullable SlidesClickedListener resendTransferClickListener;
private @Nullable Slide documentSlide;
private boolean showControls;
private Disposable awaitingDisposable = Disposable.disposed();
public DocumentView(@NonNull Context context) {
this(context, null);
@@ -98,12 +105,19 @@ public class DocumentView extends FrameLayout {
if (!EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().register(this);
}
awaitingDisposable = AttachmentBackfill.awaitingChanges()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {
if (documentSlide != null) presentTransferControls(documentSlide, showControls);
}, t -> Log.w(TAG, "Error observing backfill awaiting state.", t));
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
awaitingDisposable.dispose();
}
public void setDownloadClickListener(@Nullable SlideClickListener listener) {
@@ -126,22 +140,8 @@ public class DocumentView extends FrameLayout {
final boolean showControls,
final boolean showSingleLineFilename)
{
if (showControls && documentSlide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(stopUploadButton);
downloadProgress.spin();
stopUploadButton.setOnClickListener(new CancelTransferListener(documentSlide));
} else if (showControls && documentSlide.getUri() != null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(uploadButton);
uploadButton.setOnClickListener(new ResendTransferClickListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else if (showControls && documentSlide.getUri() == null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else {
controlToggle.displayQuick(iconContainer);
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
}
this.showControls = showControls;
presentTransferControls(documentSlide, showControls);
this.documentSlide = documentSlide;
@@ -172,6 +172,33 @@ public class DocumentView extends FrameLayout {
}
}
private void presentTransferControls(@NonNull Slide documentSlide, boolean showControls) {
DatabaseAttachment dbAttachment = documentSlide.asAttachment() instanceof DatabaseAttachment ? (DatabaseAttachment) documentSlide.asAttachment() : null;
if (dbAttachment != null && dbAttachment.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId)) {
AttachmentBackfill.onAttachmentTerminal(dbAttachment.attachmentId, dbAttachment.mmsId);
}
boolean awaitingBackfill = dbAttachment != null && AttachmentBackfill.isAwaitingBackfill(dbAttachment.attachmentId);
if (showControls && (documentSlide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED || awaitingBackfill)) {
controlToggle.displayQuick(stopUploadButton);
if (!downloadProgress.isSpinning()) downloadProgress.spin();
stopUploadButton.setOnClickListener(awaitingBackfill ? null : new CancelTransferListener(documentSlide));
} else if (showControls && documentSlide.getUri() != null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(uploadButton);
uploadButton.setOnClickListener(new ResendTransferClickListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else if (showControls && documentSlide.getUri() == null && documentSlide.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
downloadButton.setOnClickListener(new DownloadClickedListener(documentSlide));
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
} else {
controlToggle.displayQuick(iconContainer);
if (downloadProgress.isSpinning()) downloadProgress.stopSpinning();
}
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
@@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.components.subsampling.AttachmentRegionDecoder
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.util.ActionRequestListener;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.signal.core.util.bitmaps.BitmapUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.IOException;
@@ -274,8 +274,17 @@ class DividerPreferenceViewHolder(itemView: View) : MappingViewHolder<DividerPre
class SectionHeaderPreferenceViewHolder(itemView: View) : MappingViewHolder<SectionHeaderPreference>(itemView) {
private val sectionHeader: TextView = itemView.findViewById(R.id.section_header)
private val iconEndView: ImageView = itemView.findViewById(R.id.icon_end)
override fun bind(model: SectionHeaderPreference) {
sectionHeader.text = model.title.resolve(context)
val iconEnd = model.iconEnd?.resolve(context)
iconEndView.setImageDrawable(iconEnd)
iconEndView.visible = iconEnd != null
if (model.onClick != null) {
iconEndView.setOnClickListener { model.onClick() }
}
}
}
@@ -7,8 +7,8 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.JsonUtils
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
class ExportAccountDataRepository {
@@ -44,7 +44,7 @@ class ExportAccountDataRepository {
tree["text"].asText()
}
val uri = BlobProvider.getInstance()
val uri = AppDependencies.blobs
.forData(dataStr.encodeToByteArray())
.withMimeType(mimeType)
.withFileName(fileName)
@@ -27,6 +27,9 @@ interface BackupKeyCredentialManagerHandler {
/** Called when the user confirms they want to save the backup key to the password manager. */
fun onBackupKeySaveConfirmed() = updateBackupKeySaveState(BackupKeySaveState.AwaitingCredentialManager(isRetry = false))
/** Called to clear the key save state. */
fun onBackupKeySaveStateCleared() = updateBackupKeySaveState(null)
/** Handles the password manager save operation response. */
fun onBackupKeySaveCompleted(result: CredentialManagerResult) {
when (result) {
@@ -368,7 +368,7 @@ private fun RemoteBackupsSettingsContent(
promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key),
onAuthenticationFailed = {
Toast.makeText(context, R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.RemoteBackupsSettingsFragment__authentication_required, Toast.LENGTH_SHORT).show()
}
)
@@ -128,11 +128,14 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
binding.codeEntryLayout.resendSmsCountDown.startCountDownTo(state.nextSmsTimestamp.milliseconds)
binding.codeEntryLayout.callMeCountDown.startCountDownTo(state.nextCallTimestamp.milliseconds)
when (val outcome = state.changeNumberOutcome) {
is ChangeNumberOutcome.RecoveryPasswordWorked,
is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess()
is ChangeNumberOutcome.Succeeded -> changeNumberSuccess()
is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) {
presentGenericError(outcome.result)
if (outcome.result is VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
Log.i(TAG, "Verification code request rate limited; staying on code entry screen.")
} else {
presentGenericError(outcome.result)
}
}
null -> Unit
@@ -158,6 +161,8 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
when (result) {
is VerificationCodeRequestResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess()
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> presentRateLimitedDialog(retryAfterSeconds = (result.nextSmsTimestamp - System.currentTimeMillis().milliseconds).inWholeSeconds.coerceAtLeast(0))
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
else -> presentGenericError(result)
}
@@ -113,7 +113,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
}
private fun onStateUpdate(state: ChangeNumberState) {
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
if (state.changeNumberOutcome == ChangeNumberOutcome.Succeeded) {
handleSuccessfulPinEntry(state.enteredPin)
}
@@ -188,8 +188,7 @@ class ChangeNumberRepository(
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.account.setE164(e164)
SignalStore.account.setPni(pni)
SignalStore.account.setNumberAndPniIdentity(e164, pni, pniRegistrationId, pniIdentityKeyPair)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
@@ -197,9 +196,6 @@ class ChangeNumberRepository(
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
SignalStore.account.pniRegistrationId = pniRegistrationId
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
PreKeyUtil.storeSignedPreKey(pniProtocolStore, pniMetadataStore, pniSignedPreKey)
pniMetadataStore.activeSignedPreKeyId = pniSignedPreKey.id
@@ -326,6 +322,10 @@ class ChangeNumberRepository(
}
}
if (result !is NetworkResult.Success) {
result = verifyChangeAppliedDespiteError(newE164 = newE164, originalResult = result)
}
if (result is NetworkResult.StatusCodeError) {
SignalStore.misc.unlockChangeNumber()
}
@@ -336,13 +336,39 @@ class ChangeNumberRepository(
NumberChangeResult(
uuid = accountRegistrationResponse.uuid,
pni = accountRegistrationResponse.pni,
storageCapable = accountRegistrationResponse.storageCapable,
number = accountRegistrationResponse.number
)
}
)
}
/**
* The server can commit a number change but still return an error. Before trusting a non-success result, check
* with the server and see if it already reports [newE164] as our number. If it does, then the change actually
* went through, so treat it as a success instead of surfacing the error.
*/
private suspend fun verifyChangeAppliedDespiteError(newE164: String, originalResult: NetworkResult<VerifyAccountResponse>): NetworkResult<VerifyAccountResponse> {
return try {
val whoAmI = withContext(Dispatchers.IO) { whoAmI() }
if (whoAmI.number == newE164 && whoAmI.pni != null) {
Log.w(TAG, "Change number request did not succeed, but whoami reports the new number is already active. Treating the change as successful.")
NetworkResult.Success(
VerifyAccountResponse().apply {
uuid = whoAmI.aci
pni = whoAmI.pni
number = whoAmI.number
}
)
} else {
Log.i(TAG, "Change number request did not succeed and whoami does not report the new number; treating as a genuine failure.")
originalResult
}
} catch (e: IOException) {
Log.w(TAG, "Failed to query whoami while verifying change number outcome; treating as a genuine failure.", e)
originalResult
}
}
@WorkerThread
private fun createChangeNumberRequest(
sessionId: String? = null,
@@ -433,7 +459,6 @@ class ChangeNumberRepository(
data class NumberChangeResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String
)
}
@@ -43,8 +43,7 @@ data class ChangeNumberState(
)
sealed interface ChangeNumberOutcome {
data object RecoveryPasswordWorked : ChangeNumberOutcome
data object VerificationCodeWorked : ChangeNumberOutcome
data object Succeeded : ChangeNumberOutcome
class ChangeNumberRequestOutcome(val result: VerificationCodeRequestResult) : ChangeNumberOutcome
}
@@ -89,8 +89,8 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
private fun handleRequestCodeResult(changeNumberOutcome: ChangeNumberOutcome) {
Log.d(TAG, "Handling request code result: ${changeNumberOutcome.javaClass.name}")
when (changeNumberOutcome) {
is ChangeNumberOutcome.RecoveryPasswordWorked -> {
Log.i(TAG, "Successfully changed number with recovery password.")
is ChangeNumberOutcome.Succeeded -> {
Log.i(TAG, "Successfully changed number.")
changeNumberSuccess()
}
@@ -109,11 +109,27 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
is VerificationCodeRequestResult.RateLimited -> {
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> {
if (castResult.willBeAbleToRequestAgain) {
Log.i(TAG, "Verification code request rate limited; proceeding to code entry screen so the user can wait/resend rather than bailing.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberEnterCodeFragment())
} else {
Log.i(TAG, "Verification code request rate limited with no pending resend; showing rate limit error.")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
}
is VerificationCodeRequestResult.RateLimited,
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> {
Log.i(TAG, "Unable to request sms code due to rate limit")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
is VerificationCodeRequestResult.RegistrationLocked -> {
Log.i(TAG, "Destination number is registration locked; navigating to PIN entry.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberRegistrationLock(castResult.timeRemaining))
}
is VerificationCodeRequestResult.TokenNotAccepted -> {
Log.i(TAG, "Token was not accepted.")
showErrorDialog(R.string.RegistrationActivity_additional_verification_required)
@@ -125,11 +141,6 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
}
is ChangeNumberOutcome.VerificationCodeWorked -> {
Log.i(TAG, "Successfully changed number with verification code.")
changeNumberSuccess()
}
}
}
@@ -12,14 +12,12 @@ import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -36,6 +34,7 @@ import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
/**
* [ViewModel] for the change number flow.
@@ -50,12 +49,13 @@ class ChangeNumberViewModel : ViewModel() {
private val repository = ChangeNumberRepository()
private val store = MutableStateFlow(ChangeNumberState())
private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher()
private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application)
private val initialLocalNumber = SignalStore.account.e164
private val password = SignalStore.account.servicePassword!!
private val changeNumberSessionInFlight = AtomicBoolean(false)
val uiState = store.asLiveData()
val liveOldNumberState = store.map { it.oldPhoneNumber }.asLiveData()
val liveNewNumberState = store.map { it.number }.asLiveData()
@@ -261,14 +261,24 @@ class ChangeNumberViewModel : ViewModel() {
}
private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
val sessionId = getOrCreateValidSession(context)?.sessionId ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val registrationData = getRegistrationData(context)
val session = getOrCreateValidSession(context) ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val sessionId = session.sessionId
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (!session.verified) {
if (store.value.enteredCode == null) {
Log.w(TAG, "Session is not verified and no code is available to submit; cannot complete change number.")
handleVerificationError(VerificationCodeRequestResult.UnknownError(IllegalStateException("No verification code available for an unverified session")), verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to missing code for unverified session.") }
}
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
val registrationData = getRegistrationData(context)
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
}
}
val result: ChangeNumberResult = if (pin == null) {
@@ -286,7 +296,7 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is ChangeNumberResult.Success) {
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else {
handleChangeNumberError(result, numberChangeErrorHandler)
}
@@ -359,18 +369,26 @@ class ChangeNumberViewModel : ViewModel() {
fun initiateChangeNumberSession(context: Context, mode: RegistrationRepository.E164VerificationMode) {
Log.v(TAG, "changeNumber()")
if (!changeNumberSessionInFlight.compareAndSet(false, true)) {
Log.i(TAG, "A change number session is already in progress; ignoring duplicate request.")
return
}
store.update { it.copy(inProgress = true) }
viewModelScope.launch(Dispatchers.IO) {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
try {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
}
} finally {
changeNumberSessionInFlight.set(false)
}
}
}
@@ -417,7 +435,7 @@ class ChangeNumberViewModel : ViewModel() {
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
if (result is ChangeNumberResult.Success) {
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else if (result is ChangeNumberResult.UnknownError) {
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
@@ -512,6 +530,12 @@ class ChangeNumberViewModel : ViewModel() {
return
}
if (validSession.verified) {
Log.i(TAG, "Existing session is already verified; completing the change with the verified session instead of requesting a new code.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
val result = if (!validSession.allowedToRequestCode) {
val challenges = validSession.challengesRequested.joinToString()
Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges")
@@ -532,14 +556,72 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is VerificationCodeRequestResult.AlreadyVerified) {
Log.w(TAG, "Already verified, not handled properly in change number flow, attempt to recover")
resetLocalSessionState()
Log.i(TAG, "Session became verified while requesting a code; completing the change with the verified session.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
Log.d(TAG, "Received result: ${result.javaClass.canonicalName}\nwith challenges: ${challengesRequested.joinToString { it.key }}")
val (nextSmsTimestamp, nextCallTimestamp) = when (result) {
is VerificationCodeRequestResult.Success -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
else -> store.value.nextSmsTimestamp to store.value.nextCallTimestamp
}
store.update {
it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result), challengesRequested = challengesRequested, inProgress = false)
it.copy(
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result),
challengesRequested = challengesRequested,
inProgress = false,
nextSmsTimestamp = nextSmsTimestamp,
nextCallTimestamp = nextCallTimestamp
)
}
}
/**
* Completes a number change using a registration session that the service already considers verified.
*/
private suspend fun changeNumberWithVerifiedSession(sessionId: String) {
Log.v(TAG, "changeNumberWithVerifiedSession()")
when (val result = repository.changeNumberWithoutRegistrationLock(sessionId = sessionId, newE164 = number.e164Number)) {
is ChangeNumberResult.Success -> {
handleSuccessfulChangedRemoteNumber(
e164 = result.numberChangeResult.number,
pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni),
changeNumberOutcome = ChangeNumberOutcome.Succeeded
)
}
is ChangeNumberResult.RegistrationLocked -> {
if (result.svr2Credentials != null) {
Log.i(TAG, "Destination number is registration locked; prompting for PIN.")
store.update {
it.copy(
inProgress = false,
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials,
lockedTimeRemaining = result.timeRemaining,
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(
VerificationCodeRequestResult.RegistrationLocked(result.getCause(), result.timeRemaining, result.svr2Credentials, result.svr3Credentials)
)
)
}
} else {
Log.w(TAG, "Destination number is registration locked but SVR credentials were missing, cannot prompt for PIN.")
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
}
else -> {
Log.w(TAG, "Unable to complete change number with verified session.", result.getCause())
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
}
}
@@ -25,9 +25,9 @@ import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.util.bytes
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.webrtc.CallDataMode
@@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.data
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.webrtc.CallDataMode
data class DataAndStorageSettingsState(

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