Compare commits

...

228 Commits

Author SHA1 Message Date
Greyson Parrelli 5929866ae0 Bump version to 8.16.0 2026-06-17 13:49:25 -04:00
Greyson Parrelli d706fb0c4b Update baseline profile. 2026-06-17 13:26:59 -04:00
Greyson Parrelli f4185d2868 Update translations and other static files. 2026-06-17 13:17:21 -04:00
Greyson Parrelli 9430c27e64 Setup basic compose screenshot testing infra for regV5. 2026-06-17 13:08:36 -04:00
Michelle Tang b724f2b01a Turn on KT. 2026-06-17 13:08:36 -04:00
Greyson Parrelli 1e6d575ec9 Allow a backoffInterval of zero. 2026-06-17 13:08:36 -04:00
Greyson Parrelli 4c7cf5212e Remove the first PIN reminder interval. 2026-06-17 13:08:36 -04:00
Michelle Tang 33ca1132dc Update deleted messages UI. 2026-06-17 13:08:36 -04:00
andrew-signal a5e11abdc9 Bump to libsignal v0.94.5. 2026-06-17 13:08:36 -04:00
Michelle Tang 3924f65cbe Update group update margins. 2026-06-17 13:08:35 -04:00
Greyson Parrelli c500d8ecbd Prevent crash when popping back stack from a detached EnterCodeFragment. 2026-06-17 13:08:35 -04:00
Greyson Parrelli cd98fd894d Prevent crash when parsing an invalid custom donation amount. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 45a3c44d0c Prevent crash when opting out of PIN after fragment is detached. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 8ddec63e31 Make long text selectable. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 2d2a871194 Always render message details bubbles in no-wallpaper mode. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 2ef0032a33 Revert "Manually draw location on google map."
This reverts commit 02d245ac0c.
2026-06-17 13:08:35 -04:00
Greyson Parrelli 570a310e2e Do not show websocket notification for unregistered users. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 930a263174 Don't show remote mute in 1:1 calls. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 7df015ceef Improve registered check for CheckServiceReachabilityJob. 2026-06-17 13:08:35 -04:00
Greyson Parrelli 4c1555bc7b Add more checks to avoid unnecessary websocket connects. 2026-06-17 13:08:35 -04:00
Greyson Parrelli e877f43dde Fix body range bounds validation for long text messages. 2026-06-16 11:03:55 -04:00
Greyson Parrelli 52dcbb8bc6 Add a separate 'internal issues' notification channel. 2026-06-16 09:56:45 -04:00
Alex Hart e0dd576cb1 Update lint baseline. 2026-06-16 09:56:12 -04:00
Alex Hart fb746b1ad5 Add AAPT OSX verification metadata. 2026-06-16 09:56:12 -04:00
Michelle Tang ef35efe34e Update sync msg disappearing timers for calls. 2026-06-16 09:56:12 -04:00
dependabot[bot] 6a30caff87 Bump gradle/actions from 6.1.0 to 6.2.0 2026-06-16 09:56:12 -04:00
Alex Hart cb2816362c Fix crash when group story has more than 100 replies or reactions. 2026-06-16 09:56:12 -04:00
Alex Hart 5f67c9363e Fix back navigation when opening group settings from the chat list. 2026-06-16 09:56:12 -04:00
Alex Hart ba76a8323e Fix back navigation from conversation settings sub-screens popping to the chat. 2026-06-16 09:56:12 -04:00
Alex Hart aa9c7f7d7b Use detail navigation router instead of finishing activity when deleting conversation. 2026-06-16 09:56:12 -04:00
Greyson Parrelli 411a0198b4 Show media preview controls immediately. 2026-06-16 09:56:12 -04:00
Greyson Parrelli 39679ebfc3 Inline useNewLinkifier flag. 2026-06-16 09:56:12 -04:00
Alex Hart 933b799266 Utilize events instead of callbacks in MediaSend feature module. 2026-06-16 09:56:12 -04:00
Cody Henthorne d22a2c0a50 Fix transfer control progress reporting bugs. 2026-06-16 09:56:12 -04:00
Greyson Parrelli 3f682be609 Upgrade AGP to 9.2.1 2026-06-16 09:56:12 -04:00
Greyson Parrelli b16481616a Fix a bunch of lint issues. 2026-06-16 09:56:12 -04:00
Greyson Parrelli d44bef0eda Move backup status operations off the main thread. 2026-06-16 09:56:12 -04:00
Cody Henthorne f02b8001e4 Increase tap area for start/retry download. 2026-06-16 09:56:12 -04:00
Cody Henthorne fa258dcef2 Use indexes for story viewed-receipt lookup and pinned messages queries. 2026-06-16 09:56:12 -04:00
Michelle Tang fc547218d1 Turn on capability for KT username syncs. 2026-06-16 09:56:12 -04:00
Alex Hart eea29813fa Move DatabaseId and AttachmentId to core.models. 2026-06-16 09:56:12 -04:00
Alex Hart 276d71d365 Decouple add message dialog from old view model. 2026-06-16 09:56:12 -04:00
Cody Henthorne 539276673a Fix flakey BackupDeleteJobTest. 2026-06-16 09:56:12 -04:00
Greyson Parrelli d6871f8dc2 Bump version to 8.15.3 2026-06-15 19:35:19 -04:00
Greyson Parrelli d93543510f Update baseline profile. 2026-06-15 19:35:19 -04:00
Greyson Parrelli 69f7ad28ec Update translations and other static files. 2026-06-15 19:35:19 -04:00
Greyson Parrelli 8c2ff2f1c2 Improve handling of unlinked device during send. 2026-06-15 19:35:19 -04:00
Cody Henthorne 5e8cebdc87 Bump version to 8.15.2 2026-06-12 15:06:49 -04:00
Cody Henthorne c8f9c41cea Update baseline profile. 2026-06-12 15:00:45 -04:00
Cody Henthorne 647dc23de6 Update translations and other static files. 2026-06-12 14:53:10 -04:00
Cody Henthorne b0531247c3 Prevent crash when building shortcuts for large groups. 2026-06-12 14:35:15 -04:00
Greyson Parrelli f08a20d0a6 Render unread divider when the only unread message is the newest in the thread. 2026-06-12 14:10:40 -03:00
Cody Henthorne 16232e2f9f Fix transfer control showing stale data or not responding. 2026-06-12 13:01:18 -04:00
Michelle Tang fc856dd500 Turn off KT. 2026-06-12 11:22:37 -04:00
Alex Hart 73f81075ce Removes second dialog and adds learnmore. 2026-06-12 11:16:19 -03:00
Cody Henthorne c5efb2ce6c Bump version to 8.15.1 2026-06-11 14:20:28 -04:00
Cody Henthorne c7b5738787 Update baseline profile. 2026-06-11 14:13:07 -04:00
Cody Henthorne df88b7fe35 Update translations and other static files. 2026-06-11 14:03:42 -04:00
Cody Henthorne 46213b38e7 Exclude quote attachments from sticker detection during backup export. 2026-06-11 12:26:41 -04:00
Alex Hart f8b53378c6 Remove touch interceptor. 2026-06-11 13:16:23 -03:00
Greyson Parrelli bbb09eb7a0 Debounce search queries on conversation list. 2026-06-10 23:54:55 -04:00
Cody Henthorne 52ceb4062d Reject group call ring updates from senders who are not current group members. 2026-06-10 16:00:59 -04:00
Cody Henthorne f7eaa1cb51 Fix outgoing group calls showing as incoming when someone joins. 2026-06-10 16:00:59 -04:00
Cody Henthorne c62c15c810 Refresh conversation options menu when message request state changes. 2026-06-10 16:00:59 -04:00
Cody Henthorne 2389279cfd Prevent exported settings activity from honoring caller-supplied navigation graph. 2026-06-10 16:00:59 -04:00
Cody Henthorne 3e92fca26d Bump version to 8.15.0 2026-06-10 15:51:38 -04:00
Cody Henthorne 526928ce0a Update baseline profile. 2026-06-10 15:40:12 -04:00
Cody Henthorne 2ea2f561ae Update translations and other static files. 2026-06-10 15:29:25 -04:00
Cody Henthorne c88f048049 Ensure unregistered contacts update storage service if previously registered. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 204a233235 Some improvements to apkdiff. 2026-06-10 15:20:00 -04:00
BarbossHack 2e4abd8ed3 Switch to semantic resources.arsc comparison in apkdiff.
Closes signalapp/Signal-Android#14828
2026-06-10 15:20:00 -04:00
Greyson Parrelli a2a0b11c98 Improve validation on launcher alias intents. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 96f893652b Improve receipt message validations. 2026-06-10 15:20:00 -04:00
Greyson Parrelli d4924d2a13 Ignore START_ROUTE in exported settings activity when launched by another app. 2026-06-10 15:20:00 -04:00
Greyson Parrelli fa6b512cfc Improve link preview validations. 2026-06-10 15:20:00 -04:00
Alex Hart d447af36ba Surface retryable failure for slow donations config load. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 41260f37c9 Fix bug where background connection would drop in forceWebsocket mode.
Relates to #14793
2026-06-10 15:20:00 -04:00
Greyson Parrelli 8950f7f7f9 Simplify media session permission handling. 2026-06-10 15:20:00 -04:00
Greyson Parrelli 14916d068f Allow all group call participants to remote mute, not just admins. 2026-06-10 15:20:00 -04:00
emir-signal 33022baaa2 Update to RingRTC v2.69.3
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-06-10 15:20:00 -04:00
Alex Hart 4cdd1f70ac Fix contact search list flickering on query change. 2026-06-10 15:20:00 -04:00
Alex Hart 9e3ee16e65 Fix dead toolbar navigation arrow in ShareActivity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-10 15:20:00 -04:00
Michelle Tang bf912e14d9 Turn on KT. 2026-06-10 15:20:00 -04:00
Cody Henthorne 56f1a9e0ec Clear SVR auth tokens when disabling the PIN. 2026-06-10 15:20:00 -04:00
Cody Henthorne e468156c4c Verify multiple APNG lengths to prevent bad input from crashing. 2026-06-10 15:20:00 -04:00
Alex Hart 029b91066f Add warnings for phishing. 2026-06-10 15:20:00 -04:00
Alex Hart 5909a1b92a Migrate MultiselectForward to Jetpack Compose. 2026-06-09 17:21:48 -04:00
Cody Henthorne 9478cdf049 Reset SAS for device transfer on reconnect and hard abort if disconnected during transfer. 2026-06-09 17:21:48 -04:00
Michelle Tang 6d260ab63d Update announcement checks. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4bc11fcf0d Rotate IndividuaSendJobV2 remote config. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 33619fe463 Fix IndividualSendJobV2 edit message timestamps. 2026-06-09 17:21:48 -04:00
Cody Henthorne 2b17868797 Skip create remote key copy if duplicate is same attachment. 2026-06-09 17:21:48 -04:00
Alex Hart 582a464a52 Fix screen share video being center-cropped. 2026-06-09 17:21:48 -04:00
Cody Henthorne 812a858761 Reject attachments with unrecognized CDN numbers instead of crashing. 2026-06-09 17:21:48 -04:00
Alex Hart 6ea96795cb Fix detail pane state getting stuck after cancelled predictive back gesture.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:48 -04:00
Michelle Tang 70ab0baa3c Fix broken calling test. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5041057bab Update shouldIngore for edit and decryption error messages. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 6210d1b397 Improve proxy configuration. 2026-06-09 17:21:48 -04:00
Greyson Parrelli f1accae295 Fix bidi balancing character. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 5f6d20453c Improve incoming payment validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli d33385d1b2 Improve group story reply and reaction validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 3d05bc3471 Fix rules with group story replies and timers. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0514a5c6c8 Add a hard cap of stickers per pack manifest. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 798ba11e62 Validate style body ranges have a start and length to prevent message processing crash. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 848a61787b Improve device transfer wifi validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli ccfbb27695 Improve body range validations. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 26b1d3a0f8 Improve username link storage service validation. 2026-06-09 17:21:48 -04:00
Greyson Parrelli c5785c086e Strip the full set of Unicode bidi controls from attachment filenames. 2026-06-09 17:21:48 -04:00
Cody Henthorne 6aeb145024 Require Play Services send permission for SMS retriever broadcasts. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 7e8c6228d8 Improve CDN mismatch reconciliation query. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 4c08b94b88 Reduce Recipient usage in group-related jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 135bc6e560 Convert a batch of androidTest db tests to unit tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 0ebeb5aa92 Restrict S3 downloads to ReleaseChannel. 2026-06-09 17:21:48 -04:00
Greyson Parrelli aaa7a18190 Remove Robolectric from some tests with light mocking. 2026-06-09 17:21:48 -04:00
Greyson Parrelli bfc1c4ebfa Removed unnecessary Robolectric annotation from some tests. 2026-06-09 17:21:48 -04:00
Greyson Parrelli b4cf59f9c2 Reduce Recipient usage in some jobs. 2026-06-09 17:21:48 -04:00
Greyson Parrelli 15e7b30fa1 Add tests for IndividualSendJobV2. 2026-06-09 17:21:47 -04:00
Cody Henthorne a5359e05a3 Convert TransferControlView rendering to compose. 2026-06-09 17:21:47 -04:00
Michelle Tang 73557ae72a Add jitter to KT weekly check. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2505049e39 Allow WebSocketDrainer to run longer if network returns while waiting. 2026-06-09 17:21:47 -04:00
Cody Henthorne a4ae6581ef Improve website apk update flow. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6399a2d899 Avoid taking a transaction in RemappedRecordTables. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 56af57db9e Un-export FCM service. 2026-06-09 17:21:47 -04:00
Greyson Parrelli ddf0de52b1 Log transaction waits as separate issues. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 925e2c1705 Fix potential recursive call. 2026-06-09 17:21:47 -04:00
Greyson Parrelli cb719dff1a Reduce logging verbosity. 2026-06-09 17:21:47 -04:00
Cody Henthorne f2fd3e63c8 Prevent accepted group message request from resetting when restored from storage. 2026-06-09 17:21:47 -04:00
Michelle Tang 8560ab0515 Expire calls from call log. 2026-06-09 17:21:47 -04:00
Michelle Tang d586eff80b Expire group calls. 2026-06-09 17:21:47 -04:00
Alex Hart 27ddd62d7a Keep user in gift flow after payment error instead of finishing the activity.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 38f31528ff Fix mob stringification in backups.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-09 17:21:47 -04:00
Greyson Parrelli 335fcd72f3 Don't flag regV5 PIN entry as a password. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 118231a328 Attempt to restore AccountRecord in regV5. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 754dd15c94 Add device transfer flow to regV5. 2026-06-09 17:21:47 -04:00
Cody Henthorne 566c2d5838 Always scroll to the top of the conversation header when in message request state. 2026-06-09 17:21:47 -04:00
Greyson Parrelli eae894152c Add a new ci task with faster lint. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 53c4069c64 Track perf issues in a table, add internal viewer. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 65082893db Add index for scheduled message queries. 2026-06-09 17:21:47 -04:00
ArseniiS 595364b522 Convert HelpFragment to Compose.
Co-authored-by: Alex Hart <alex@signal.org>
2026-06-09 17:21:47 -04:00
Alex Hart 7cce504f16 Fix unread divider placement and scroll-to-unread on conversation open. 2026-06-09 17:21:47 -04:00
Alex Hart 337afb11db Add screen size check for link and sync. 2026-06-09 17:21:47 -04:00
Greyson Parrelli 6027d58fb5 Fix various lint issues. 2026-06-09 17:21:47 -04:00
jeffrey-signal 132eaa5c70 Adjust tablet preview dimensions to better align with the current WindowBreakpoint config. 2026-06-09 17:21:47 -04:00
jeffrey-signal ba3e15ea6d RegV5 text style and padding fixes. 2026-06-09 17:21:47 -04:00
jeffrey-signal ceee5f714d Adjust RegV5 window insets so screens draw under system bars and handle display cutouts. 2026-06-09 17:21:47 -04:00
jeffrey-signal 4c942f39b0 Elevate RegistrationScaffold footer surface when content scrolls underneath. 2026-06-09 17:21:47 -04:00
jeffrey-signal f3a5bba3f2 Enable TwoPaneRegistrationScaffold panes to scroll independently. 2026-06-09 17:21:47 -04:00
jeffrey-signal 8af7606e3f Use TopAppBar on all RegV5 screens. 2026-06-09 17:21:47 -04:00
Cody Henthorne 2adf84a895 Bump version to 8.14.3 2026-06-09 17:06:45 -04:00
Cody Henthorne 30ed0aa11a Update baseline profile. 2026-06-09 17:01:17 -04:00
Cody Henthorne ec9ae9e3b1 Update translations and other static files. 2026-06-09 16:48:06 -04:00
Cody Henthorne 6a23896077 Fix view state restore crash from LinkPreviewView sharing an id with its ViewStub. 2026-06-09 16:22:57 -04:00
Greyson Parrelli f5a1d79eb5 Ensure we don't run the SVR migration for unregistered users. 2026-06-09 16:20:28 -04:00
Alex Hart 4f0f0938d8 Bump version to 8.14.2 2026-06-05 16:38:39 -03:00
Alex Hart 0136971963 Update translations and other static files. 2026-06-05 16:25:51 -03:00
Michelle Tang f810d731dd Turn off KT. 2026-06-05 14:32:07 -04:00
Cody Henthorne 7c7c364fef Fix sending quoted voice notes in 1:1 chats via IndividualSendJobV2.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-05 14:28:16 -04:00
Alex Hart aa9591211b Bump version to 8.14.1 2026-06-04 16:21:46 -03:00
Alex Hart bbd48547e5 Update translations and other static files. 2026-06-04 16:08:30 -03:00
Cody Henthorne 757b521744 Add additional logging around message request interactions. 2026-06-04 13:59:47 -04:00
Cody Henthorne a6311c87c1 Cleanup bad notified state in background instead of during db migration. 2026-06-04 13:55:38 -04:00
Cody Henthorne 045bd9287b Fix incorrect quote and link preview compose layout. 2026-06-04 11:30:37 -04:00
Cody Henthorne f1a72dd01a Use CDN number instead of parsing identifier for attachment remote id. 2026-06-04 11:22:14 -04:00
Cody Henthorne af4d0a0ef0 Fix illegal session state crashes in receipt send flows. 2026-06-04 11:21:46 -04:00
Greyson Parrelli 7dcaa933f2 Rotate IndividuaSendJobV2 remote config. 2026-06-04 00:36:04 -04:00
Greyson Parrelli 2c88945e6b Fix sending messages to self when your session is deleted. 2026-06-04 00:35:40 -04:00
Greyson Parrelli f9b9ce6c14 Fix character swapping during backup restore. 2026-06-03 23:57:04 -04:00
Michelle Tang 1d8fbad17e Add additional unauthorized KT check. 2026-06-03 17:14:40 -04:00
Alex Hart 6872a14378 Bump version to 8.14.0 2026-06-03 15:18:58 -03:00
Alex Hart 3a1eb4bd88 Update translations and other static files. 2026-06-03 15:11:03 -03:00
Michelle Tang d9f93294e4 Turn on KT. 2026-06-03 15:06:31 -03:00
Greyson Parrelli f063c43b52 Fix session initialization in MessageService. 2026-06-03 15:06:31 -03:00
Michelle Tang a7ed672634 Stop unregistered KT failures. 2026-06-03 15:06:31 -03:00
Michelle Tang 1371663163 Add capability for KT username syncs. 2026-06-03 15:06:31 -03:00
Alex Hart 1f0c24a5d5 Add bank transfer fix for pill size and state readout. 2026-06-03 15:06:31 -03:00
Cody Henthorne b732cbe00b Disable video mirroring for screen share preview in calls. 2026-06-03 15:06:31 -03:00
dependabot[bot] 85d60dd0da Bump GH actions versions for checkout and stale. 2026-06-03 15:06:31 -03:00
andrew-signal c020bfeb7a Bump to libsignal v0.94.4 2026-06-03 15:06:31 -03:00
Greyson Parrelli 3ad446c6c9 Render legacy encryption error messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli bc9c560f96 Add support for optional remote build cache. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 2b54dc4715 Ensure we uppercase AEP entry, add tests. 2026-06-03 15:06:31 -03:00
Michelle Tang 1443457eca Fix pluralization of pin attempt string. 2026-06-03 15:06:31 -03:00
Cody Henthorne ffbc4465bb Improve attachment and delete database operations. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4e5ddad78f Fixed linkifying URLs with commas. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 47a69d667c Hide video overlay stub when binding text-only quoted messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 9d3a51def2 Check battery optimization status before showing general battery saver prompt. 2026-06-03 15:06:31 -03:00
Greyson Parrelli b8c964846c Add gradle cache support to CI. 2026-06-03 15:06:31 -03:00
jeffrey-signal b02210c166 Fix medium sized devices using bar navigation instead of rail navigation. 2026-06-03 15:06:31 -03:00
Greyson Parrelli c2f8261419 Use better index for story query. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 089d47936b Log query analysis of slow queries for internal users. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 48f55bba0a Add search to internal preferences. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 348387f2d0 Fix v2 message sync sends over gRPC. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 30c0ef255a Upgrade wire to 6.4.0 2026-06-03 15:06:31 -03:00
Greyson Parrelli 64d3ba9e5b Add android tools checksum to Dockerfile. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 930a2f052a Remove SDK guard on voice note comparison. 2026-06-03 15:06:31 -03:00
Greyson Parrelli efec070728 Force screen security flag on PIN screens. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 99aa8a602b Force a WAL checkpoint after message deletions. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4a68e0c469 Fix backup progress being stuck. 2026-06-03 15:06:31 -03:00
Michelle Tang be80619a3b Disappear 1:1 calls. 2026-06-03 15:06:31 -03:00
fethij a0d605d1b1 Fix integer division in failed-service-start heuristic 2026-06-03 15:06:31 -03:00
Alex Hart 64f30bff47 Use scrollable tab mode for donation receipt list to prevent text truncation.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 15:06:31 -03:00
Greyson Parrelli 843b656fb6 Use specific remote config for slow database notification. 2026-06-03 15:06:31 -03:00
Cody Henthorne f5f5bf0a67 Use view stubs for heavy, low use compose drafting views. 2026-06-03 15:06:31 -03:00
Cody Henthorne bf73954f42 Add indexes for slow database queries and optimize view-once query. 2026-06-03 13:55:36 -04:00
Cody Henthorne 1fd651ee50 Fix answered ringing group calls getting marked as missed. 2026-06-03 13:55:36 -04:00
Greyson Parrelli f76292769a Update MessageApiV2 to use libsignal-net. 2026-06-03 13:55:36 -04:00
Cody Henthorne 51c4afe5f5 Add missing unidentified status data to individual send v2 sync message. 2026-06-03 13:55:36 -04:00
Cody Henthorne fe435433fd Add missing handler for group request join/cancel update.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 13:55:36 -04:00
andrew-signal fa8098a9aa Bump to libsignal v0.94.2. 2026-06-03 13:55:36 -04:00
BarbossHack 81e09e65cb Fix resources.arsc comparison in reproducible script.
Resolves signalapp/Signal-Android#14817
2026-06-03 13:55:36 -04:00
jeffrey-signal 1059fcafba Fix minimum length hint on RegV5 PIN creation screen. 2026-06-03 13:55:36 -04:00
Cody Henthorne 91d3fa8ad5 Fix bugs around stalled media restore and add recovery logic. 2026-06-03 13:55:36 -04:00
Cody Henthorne 89bffe39ae Improve cold start performance. 2026-06-03 13:55:36 -04:00
Cody Henthorne de2a5ea440 Add internal slow database transaction logging and alerting. 2026-06-03 13:55:36 -04:00
Cody Henthorne a54d62c09d Fix db access on main thread during conversation open. 2026-06-03 13:55:36 -04:00
jeffrey-signal e8785218a5 Add RegV5 confirm PIN UI. 2026-06-03 13:55:36 -04:00
Cody Henthorne e6e6075c9b Fix potential Recipient.resolve on main thread for group thread list item. 2026-06-03 13:55:36 -04:00
Alex Hart 95b69faa58 Cap persisted image-editor undo history to avoid crash. 2026-06-03 13:55:36 -04:00
Cody Henthorne b7f09ef923 Retry delivery receipt send on generic IO failures. 2026-06-03 13:55:36 -04:00
Alex Hart 50884e144e Add linked device registration skeleton UI. 2026-06-03 13:55:35 -04:00
Alex Hart 274feb168e Fix story preview thumbnails not rendering in contact picker. 2026-06-03 13:55:35 -04:00
jeffrey-signal 5b1f5a2a20 Ensure consistent regV5 footer padding and elevation. 2026-06-03 13:55:35 -04:00
dependabot[bot] 2cb9685024 Bump actions/stale from 10.2.0 to 10.3.0 in the actions group.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 13:55:35 -04:00
Greyson Parrelli 0f4fc74829 Reduce unit test parallelism. 2026-06-03 13:55:35 -04:00
Cody Henthorne f5e8e15785 Add index for notification state query. 2026-06-03 13:55:35 -04:00
jeffrey-signal 3ff1501090 Adaptive PIN entry screen. 2026-06-03 13:55:35 -04:00
Greyson Parrelli 0907898105 Have QA task use debug variants. 2026-06-03 13:55:35 -04:00
Cody Henthorne 5fd8101180 Fix spinner build. 2026-06-03 13:55:35 -04:00
925 changed files with 44271 additions and 51930 deletions
+1
View File
@@ -1 +1,2 @@
*.ai binary
**/src/screenshotTest*/reference/**/*.png filter=lfs diff=lfs merge=lfs -text
+21 -5
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- 'main'
- '7.**'
- '8.**'
permissions:
contents: read # to fetch code (actions/checkout)
@@ -16,10 +16,11 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
lfs: true
- name: set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
@@ -27,14 +28,29 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
with:
# Only 8.** branch builds write to the cache; everything else (PRs, etc.) reads only.
cache-read-only: ${{ !startsWith(github.ref, 'refs/heads/8.') }}
# Required to persist the Gradle configuration cache across runs.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# Pull requests run the fast custom linter (ci); pushes to main / 8.x branches run the full
# Android lint (qa).
- name: Build with Gradle
run: ./gradlew qa
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
- name: Archive reports for failed build
if: ${{ failure() }}
+12 -4
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
@@ -28,13 +28,21 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
with:
# PR-only workflow: always read from the cache, never write.
cache-read-only: true
# Required to read the Gradle configuration cache persisted by 8.** builds.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Cache base apk
@@ -53,7 +61,7 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
actions: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
+2 -2
View File
@@ -27,8 +27,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1699
val canonicalVersionName = "8.13.1"
val canonicalVersionCode = 1708
val canonicalVersionName = "8.16.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
+423 -36814
View File
File diff suppressed because one or more lines are too long
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.CDN_3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -0,0 +1,393 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isGreaterThanOrEqualTo
import assertk.assertions.isLessThan
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* End-to-end UI test of the unread divider. Seeds a thread with many unread messages and opens it via the notification
* path (which enters the conversation with no explicit jump point — functionally "open a chat with X unread"), then
* verifies the real pipeline (repository -> view model -> fragment -> decoration) anchors the divider to the oldest
* unread message and scrolls there rather than opening at the bottom.
*
* The launch harness mirrors [org.thoughtcrime.securesms.main.MainNavigationLaunchTest]: ActivityScenario can't track
* MainActivity launched with a custom-action intent, so we start it via Application#startActivity and observe lifecycle
* callbacks instead.
*/
@RunWith(AndroidJUnit4::class)
class UnreadDividerInstrumentationTest {
@get:Rule
val harness = SignalActivityRule(othersCount = 2)
@Test
fun opensScrolledToOldestUnreadWithCorrectDividerState() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val totalUnread = 50
val oldestSentTime = 1000L
var oldestUnreadId = -1L
for (i in 0 until totalUnread) {
val id = insertIncoming(threadId, recipientId, time = oldestSentTime + i, body = "unread $i")
if (i == 0) {
oldestUnreadId = id
}
}
// Derive expectations from the DB the same way the app does, so the test is robust to any extra system rows.
val expectedUnreadCount = SignalDatabase.messages.getUnreadCount(threadId)
val firstUnreadPosition = SignalDatabase.messages.getMessagePositionByDateReceivedTimestamp(threadId, oldestSentTime, false)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "conversation scrolled to oldest unread") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
val state = decoration.unreadStateForTesting as? ConversationItemDecorations.UnreadState.CompleteUnreadState ?: return@await null
val view = recycler.layoutManager?.findViewByPosition(firstUnreadPosition) ?: return@await null
Observed(state.unreadCount, state.firstUnreadId, view.top, recycler.height)
}
assertThat(result.unreadCount).isEqualTo(expectedUnreadCount)
assertThat(result.firstUnreadId).isEqualTo(oldestUnreadId)
// The oldest unread is laid out in the top half -> we scrolled up to it instead of opening at the bottom (where,
// with this many messages, it would be off-screen above and findViewByPosition would have returned null).
assertThat(result.firstUnreadTop).isGreaterThanOrEqualTo(0)
assertThat(result.firstUnreadTop).isLessThan(result.recyclerHeight / 2)
}
}
@Test
fun fullyReadConversationOpensAtBottomWithoutDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "read $i")
}
SignalDatabase.threads.setRead(threadId)
// Precondition: nothing is unread, so there should be no divider.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isEqualTo(0)
launch(recipientId).use { launched ->
val result = await(timeoutMs = 20_000, description = "fully-read conversation opened at the bottom") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// The newest message is position 0; if it's laid out, the list loaded and settled at the bottom.
val newest = recycler.layoutManager?.findViewByPosition(0) ?: return@await null
BottomObserved(decoration.unreadStateForTesting, newest.bottom, recycler.height)
}
assertThat(result.unreadState).isEqualTo(ConversationItemDecorations.UnreadState.None)
// Newest message sits in the lower half -> opened at the bottom (with this many messages it would be off-screen
// below if we'd opened at the top).
assertThat(result.newestBottom).isGreaterThan(result.recyclerHeight / 2)
}
}
@Test
fun outgoingMessageNewerThanUnreadClearsDivider() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
// A few unread incoming messages, then a newer outgoing reply. Kept small so all rows load in the initial page.
insertIncoming(threadId, recipientId, time = 1000L, body = "unread 0")
insertIncoming(threadId, recipientId, time = 1001L, body = "unread 1")
insertIncoming(threadId, recipientId, time = 1002L, body = "unread 2")
val outgoing = OutgoingMessage.text(
threadRecipient = Recipient.resolved(recipientId),
body = "my reply",
expiresIn = 0,
sentTimeMillis = 1003L
)
SignalDatabase.messages.insertMessageOutbox(outgoing, threadId)
// Precondition: the messages are still unread at the DB level, so the divider would show if it weren't for the
// newer outgoing message clearing it.
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
launch(recipientId).use { launched ->
val cleared = await(timeoutMs = 20_000, description = "divider cleared by newer outgoing message") {
val fragment = launched.latestConversationFragment() ?: return@await null
val recycler = fragment.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler) ?: return@await null
val decoration = recycler.conversationItemDecorations() ?: return@await null
// Wait until the list has loaded (outgoing at position 0 laid out) before reading the resolved state.
recycler.layoutManager?.findViewByPosition(0) ?: return@await null
if (decoration.unreadStateForTesting == ConversationItemDecorations.UnreadState.None) true else null
}
assertThat(cleared).isEqualTo(true)
}
}
@Test
fun scrollingToBottomMarksEverythingReadAndDrainsUnreadCount() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
// getUnreadCount is the shared source for the chat-list badge and the scroll-to-bottom button's count, so
// asserting on it verifies the number the user sees updating as they scroll.
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0)
// Jump to the newest message; revealing it marks every earlier message read (MarkReadHelper.onViewsRevealed).
runOnMain {
launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)?.scrollToPosition(0)
}
// Scrolling through the thread drains the unread count to 0.
await(timeoutMs = 20_000, description = "unread count reaches 0 after scrolling to the bottom") {
if (SignalDatabase.messages.getUnreadCount(threadId) == 0) true else null
}
}
}
@Test
fun scrollingPartwayLeavesExactlyTheUnreadMessagesBelowTheViewport() {
val recipientId = harness.others.first()
SignalDatabase.recipients.setProfileSharing(recipientId, true)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val total = 50
for (i in 0 until total) {
insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i")
}
launch(recipientId).use { launched ->
await(timeoutMs = 20_000, description = "conversation loaded") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
if ((recycler?.childCount ?: 0) > 0) true else null
}
// The chat opens at the oldest unread (near the top); scroll down to roughly the middle.
runOnMain {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(total / 2, 0)
}
// Once mark-read settles, the unread count must equal the index of the newest visible message — i.e. exactly the
// messages still below the viewport (reverse layout: position 0 = newest, so index N = N newer messages). This is
// the number the scroll-to-bottom button and chat-list badge show; it must not over- or under-count mid-scroll.
val stableCount = awaitStableUnreadCount(threadId)
val newestVisiblePosition = await(timeoutMs = 5_000, description = "newest visible position") {
val recycler = launched.latestConversationFragment()?.view?.findViewById<RecyclerView>(R.id.conversation_item_recycler)
(recycler?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.takeIf { it >= 0 }
}
assertThat(stableCount).isEqualTo(newestVisiblePosition)
// Sanity: we exercised a genuine mid-scroll point, not the very top or bottom.
assertThat(stableCount).isGreaterThan(0)
assertThat(stableCount).isLessThan(total)
}
}
/** Polls [MessageTable.getUnreadCount] until it holds steady (mark-read is debounced + async), then returns it. */
private fun awaitStableUnreadCount(threadId: Long, timeoutMs: Long = 20_000): Int {
val deadline = System.currentTimeMillis() + timeoutMs
var last = Int.MIN_VALUE
var stableSince = System.currentTimeMillis()
while (System.currentTimeMillis() < deadline) {
val current = SignalDatabase.messages.getUnreadCount(threadId)
if (current == last) {
if (System.currentTimeMillis() - stableSince >= 500) {
return current
}
} else {
last = current
stableSince = System.currentTimeMillis()
}
Thread.sleep(100)
}
throw AssertionError("Unread count never stabilized (last observed = $last)")
}
private data class BottomObserved(
val unreadState: ConversationItemDecorations.UnreadState,
val newestBottom: Int,
val recyclerHeight: Int
)
private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, body: String): Long {
val message = IncomingMessage(
type = MessageType.NORMAL,
from = from,
sentTimeMillis = time,
serverTimeMillis = time,
receivedTimeMillis = time,
body = body
)
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
}
private data class Observed(
val unreadCount: Int,
val firstUnreadId: Long,
val firstUnreadTop: Int,
val recyclerHeight: Int
)
private fun RecyclerView.conversationItemDecorations(): ConversationItemDecorations? {
for (i in 0 until itemDecorationCount) {
val decoration = getItemDecorationAt(i)
if (decoration is ConversationItemDecorations) {
return decoration
}
}
return null
}
private fun runOnMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync { block() }
}
/** Polls [block] on the main thread until it returns non-null, failing after [timeoutMs]. */
private fun <T> await(timeoutMs: Long, pollMs: Long = 100, description: String, block: () -> T?): T {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
var value: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync { value = block() }
if (value != null) {
return value!!
}
Thread.sleep(pollMs)
}
throw AssertionError("Timed out after ${timeoutMs}ms waiting for $description")
}
private fun launch(recipientId: RecipientId): Launched {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
val resumed = CountDownLatch(1)
val conversationFragments: MutableList<ConversationFragment> = Collections.synchronizedList(mutableListOf())
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
val fragmentCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
if (f is ConversationFragment) {
conversationFragments.add(f)
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
if (f is ConversationFragment) {
conversationFragments.remove(f)
}
}
}
val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
allActivities.add(activity)
if (activity is MainActivity) {
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true)
}
}
override fun onActivityResumed(activity: Activity) {
if (activity is MainActivity) {
resumed.countDown()
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
allActivities.remove(activity)
}
}
app.registerActivityLifecycleCallbacks(activityCallbacks)
// Open the conversation the way a notification tap does: a conversation intent with no starting position.
val conversationIntent = ConversationIntents.createBuilder(harness.context, recipientId, -1L).blockingGet().build()
val intent = Intent(harness.context, MainActivity::class.java).apply {
action = ConversationIntents.ACTION
putExtras(conversationIntent)
// Application#startActivity from a non-Activity context requires a new task.
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
app.startActivity(intent)
} catch (t: Throwable) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw t
}
if (!resumed.await(15, TimeUnit.SECONDS)) {
app.unregisterActivityLifecycleCallbacks(activityCallbacks)
throw AssertionError("MainActivity did not reach RESUMED within 15s")
}
return Launched(conversationFragments, app, activityCallbacks, allActivities)
}
private class Launched(
private val conversationFragments: List<ConversationFragment>,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : AutoCloseable {
fun latestConversationFragment(): ConversationFragment? = synchronized(conversationFragments) { conversationFragments.lastOrNull() }
override fun close() {
val toFinish = synchronized(allActivities) { allActivities.toList() }
if (toFinish.isNotEmpty()) {
InstrumentationRegistry.getInstrumentation().runOnMainSync {
toFinish.forEach { it.finish() }
}
}
app.unregisterActivityLifecycleCallbacks(callbacks)
}
}
}
@@ -20,6 +20,7 @@ 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
@@ -27,7 +28,6 @@ import org.signal.core.util.copyTo
import org.signal.core.util.stream.NullOutputStream
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
@@ -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,13 +12,13 @@ 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.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -431,7 +431,7 @@ class CallTableTest {
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(1L, call?.timestamp)
}
@@ -1,50 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
class InAppPaymentTableTest {
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
}
@Test
fun givenACreatedInAppPayment_whenIUpdateToPending_thenIExpectPendingPayment() {
val inAppPaymentId = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData()
)
val paymentBeforeUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentBeforeUpdate?.state).isEqualTo(InAppPaymentTable.State.CREATED)
SignalDatabase.inAppPayments.update(
inAppPayment = paymentBeforeUpdate!!.copy(state = InAppPaymentTable.State.PENDING)
)
val paymentAfterUpdate = SignalDatabase.inAppPayments.getById(inAppPaymentId)
assertThat(paymentAfterUpdate?.state).isEqualTo(InAppPaymentTable.State.PENDING)
}
}
@@ -1,174 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.UuidUtil
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord
import org.whispersystems.signalservice.api.storage.StorageId
import java.time.DayOfWeek
import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.NotificationProfile as RemoteNotificationProfile
import org.whispersystems.signalservice.internal.storage.protos.Recipient as RemoteRecipient
@RunWith(AndroidJUnit4::class)
class NotificationProfileTablesTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var alice: RecipientId
private lateinit var profile1: NotificationProfile
@Before
fun setUp() {
alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
profile1 = NotificationProfile(
id = 1,
name = "profile1",
emoji = "",
createdAt = 1000L,
schedule = NotificationProfileSchedule(id = 1),
allowedMembers = setOf(alice),
notificationProfileId = NotificationProfileId.generate(),
deletedTimestampMs = 0,
storageServiceId = StorageId.forNotificationProfile(byteArrayOf(1, 2, 3))
)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileScheduleTable.TABLE_NAME)
SignalDatabase.notificationProfiles.writableDatabase.deleteAll(NotificationProfileTables.NotificationProfileAllowedMembersTable.TABLE_NAME)
}
@Test
fun givenARemoteProfile_whenIInsertLocally_thenIExpectAListWithThatProfile() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
allowedMembers = listOf(RemoteRecipient(RemoteRecipient.Contact(Recipient.resolved(alice).serviceId.get().toString()))),
allowAllMentions = false,
allowAllCalls = true,
scheduleEnabled = false,
scheduleStartTime = 900,
scheduleEndTime = 1700,
scheduleDaysEnabled = emptyList(),
deletedAtTimestampMs = 0
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
val actualProfiles = SignalDatabase.notificationProfiles.getProfiles()
assertEquals(listOf(profile1), actualProfiles)
}
@Test
fun givenAProfile_whenIDeleteIt_thenIExpectAnEmptyList() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
assertThat(SignalDatabase.notificationProfiles.getProfiles()).isEmpty()
assertThat(SignalDatabase.notificationProfiles.getProfile(profile.id))
}
@Test
fun givenADeletedProfile_whenIGetIt_thenIExpectItToStillHaveASchedule() {
val profile: NotificationProfile = SignalDatabase.notificationProfiles.createProfile(
name = "Profile",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
).profile
SignalDatabase.notificationProfiles.deleteProfile(profile.id)
val deletedProfile = SignalDatabase.notificationProfiles.getProfile(profile.id)!!
assertThat(deletedProfile.schedule.enabled).isFalse()
assertThat(deletedProfile.schedule.start).isEqualTo(900)
assertThat(deletedProfile.schedule.end).isEqualTo(1700)
assertThat(deletedProfile.schedule.daysEnabled, "Contains correct default days")
.containsExactlyInAnyOrder(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)
}
@Test
fun givenNotificationProfiles_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
SignalDatabase.notificationProfiles.createProfile(
name = "Profile1",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 1000L
)
SignalDatabase.notificationProfiles.createProfile(
name = "Profile2",
emoji = "avatar",
color = AvatarColor.A210,
createdAt = 2000L
)
val existingMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, _) ->
SignalDatabase.notificationProfiles.applyStorageIdUpdate(id, StorageId.forNotificationProfile(StorageSyncHelper.generateKey()))
}
val updatedMap = SignalDatabase.notificationProfiles.getStorageSyncIdsMap()
existingMap.forEach { (id, storageId) ->
assertNotEquals(storageId, updatedMap[id])
}
}
@Test
fun givenAProfileDeletedOver30Days_whenICleanUp_thenIExpectItToNotHaveAStorageId() {
val remoteRecord =
SignalNotificationProfileRecord(
profile1.storageServiceId!!,
RemoteNotificationProfile(
id = UuidUtil.toByteArray(profile1.notificationProfileId.uuid).toByteString(),
name = "profile1",
emoji = "",
color = profile1.color.colorInt(),
createdAtMs = 1000L,
deletedAtTimestampMs = 1000L
)
)
SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord)
SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis())
assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty()
}
private val NotificationProfileTables.NotificationProfileChangeResult.profile: NotificationProfile
get() = (this as NotificationProfileTables.NotificationProfileChangeResult.Success).notificationProfile
}
@@ -8,10 +8,17 @@ package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNotEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.nullIfBlank
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageRecordUpdate
@@ -19,8 +26,11 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.signalAci
import org.whispersystems.signalservice.api.storage.signalPni
import org.whispersystems.signalservice.api.storage.toSignalContactRecord
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -60,4 +70,46 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
val messages = MessageTableTestUtils.getMessages(SignalDatabase.threads.getThreadIdFor(other.id)!!)
assertThat(messages.first().isIdentityDefault).isTrue()
}
@Test
fun givenAnAlreadySyncedContact_whenMarkedUnregistered_thenItSplitsAndPublishesTheSplit() {
// GIVEN a registered contact with aci+pni+e164 that is already in storage service (has a storageId)
val aci = ACI.from(UUID.randomUUID())
val pni = PNI.from(UUID.randomUUID())
val e164 = "+13334445555"
val id = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164)
SignalDatabase.recipients.markRegistered(id, aci)
val originalStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(originalStorageId).isNotNull()
// Sanity: the record it currently publishes is whole + registered.
val before = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(before.signalAci).isEqualTo(aci)
assertThat(before.signalPni).isEqualTo(pni)
assertThat(before.unregisteredAtTimestamp).isEqualTo(0L)
// WHEN it is marked unregistered (which strips its pni/e164 and splits it)
SignalDatabase.recipients.markUnregistered(id)
// THEN its storageId rotates
val updatedStorageId: ByteArray? = SignalDatabase.recipients.getRecord(id).storageId
assertThat(updatedStorageId).isNotNull()
assertThat(originalStorageId!!.contentEquals(updatedStorageId!!)).isFalse()
// THEN the published record is now ACI-only + unregistered
val after = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(id)!!).proto.contact!!
assertThat(after.signalAci).isEqualTo(aci)
assertThat(after.signalPni).isNull()
assertThat(after.e164.nullIfBlank()).isNull()
assertThat(after.unregisteredAtTimestamp > 0L).isTrue()
// THEN the number now lives on a separate PNI-only recipient, so no whole aci+pni+e164 record remains.
val byPni = SignalDatabase.recipients.getByPni(pni).get()
assertThat(byPni).isNotEqualTo(id)
val pniRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(byPni)!!).proto.contact!!
assertThat(pniRecord.signalAci).isNull()
assertThat(pniRecord.signalPni).isEqualTo(pni)
}
}
@@ -1,120 +0,0 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.SqlUtil
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.whispersystems.signalservice.api.push.DistributionId
import java.util.UUID
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
@RunWith(AndroidJUnit4::class)
class MyStoryMigrationTest {
@get:Rule val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
@Test
fun givenAValidMyStory_whenIMigrate_thenIExpectMyStoryToBeValid() {
// GIVEN
assertValidMyStoryExists()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenNoMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
deleteMyStory()
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenA00000000DistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId("0000-0000")
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
@Test
fun givenARandomDistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
// GIVEN
setMyStoryDistributionId(UUID.randomUUID().toString())
// WHEN
runMigration()
// THEN
assertValidMyStoryExists()
}
private fun setMyStoryDistributionId(serializedId: String) {
SignalDatabase.rawDatabase.update(
DistributionListTables.LIST_TABLE_NAME,
contentValuesOf(
DistributionListTables.DISTRIBUTION_ID to serializedId
),
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun deleteMyStory() {
SignalDatabase.rawDatabase.delete(
DistributionListTables.LIST_TABLE_NAME,
"_id = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY)
)
}
private fun assertValidMyStoryExists() {
SignalDatabase.rawDatabase.query(
DistributionListTables.LIST_TABLE_NAME,
SqlUtil.COUNT,
"_id = ? AND ${DistributionListTables.DISTRIBUTION_ID} = ?",
SqlUtil.buildArgs(DistributionListId.MY_STORY, DistributionId.MY_STORY.toString()),
null,
null,
null
).use {
if (it.moveToNext()) {
val count = it.getInt(0)
assertEquals("assertValidMyStoryExists: Query produced an unexpected count.", 1, count)
} else {
fail("assertValidMyStoryExists: Query did not produce a count.")
}
}
}
private fun runMigration() {
V151_MyStoryMigration.migrate(
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
SignalSQLiteDatabase(SignalDatabase.rawDatabase),
0,
1
)
}
}
@@ -19,16 +19,22 @@ 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.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.UUID
class BackupDeleteJobTest {
@@ -155,10 +161,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 +255,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()
)
}
}
@@ -143,6 +143,7 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenUserIsNotRegistered_whenIRun_thenIExpectSuccessAndEarlyExit() {
mockkObject(SignalStore.account) {
every { SignalStore.account.e164 } returns "+15555550101"
every { SignalStore.account.isRegistered } returns false
val job = BackupSubscriptionCheckJob.create()
@@ -155,6 +156,7 @@ class BackupSubscriptionCheckJobTest {
@Test
fun givenIsLinkedDevice_whenIRun_thenIExpectSuccessAndEarlyExit() {
mockkObject(SignalStore.account) {
every { SignalStore.account.e164 } returns "+15555550101"
every { SignalStore.account.isLinkedDevice } returns true
val job = BackupSubscriptionCheckJob.create()
@@ -139,7 +139,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@@ -241,5 +241,6 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
override fun setMultiDevice(isMultiDevice: Boolean) = throw UnsupportedOperationException()
}
}
@@ -146,7 +146,7 @@ object TestMessages {
private fun imageAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -170,7 +170,7 @@ object TestMessages {
private fun voiceAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"audio/aac",
null,
Optional.empty(),
@@ -133,7 +133,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@@ -157,7 +157,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)
+1 -1
View File
@@ -1371,7 +1371,7 @@
<service
android:name=".gcm.FcmReceiveService"
android:exported="true">
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,11 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
private var afterScroll: (() -> Unit)? = null
// Backing state for scrollToPositionTopAligned; alignTopCorrected guards the one-shot corrective re-scroll.
private var alignTopPosition: Int = RecyclerView.NO_POSITION
private var alignTopInset: Int = 0
private var alignTopCorrected: Boolean = false
override fun supportsPredictiveItemAnimations(): Boolean {
return false
}
@@ -34,9 +39,23 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
*/
fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = RecyclerView.NO_POSITION
super.scrollToPositionWithOffset(position, offset)
}
/**
* Scroll so [position]'s decorated top (including any top decoration, e.g. the unread divider) lands [topInset] px
* below the top of the recycler. [afterScroll] fires once the alignment settles.
*/
fun scrollToPositionTopAligned(position: Int, topInset: Int, afterScroll: () -> Unit) {
this.afterScroll = afterScroll
alignTopPosition = position
alignTopInset = topInset
alignTopCorrected = false
// Rough first pass: the exact offset needs the item's height, which isn't known until it's laid out (see onLayoutCompleted).
super.scrollToPositionWithOffset(position, height - topInset)
}
/**
* If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source,
* the base implementation clears the request as if it was never made.
@@ -64,10 +83,26 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context,
} else {
scrollToPosition(pendingScrollPosition)
}
} else {
afterScroll?.invoke()
afterScroll = null
return
}
// The target is now laid out, so its height is known. Correct the offset once so the decorated top sits at the
// requested inset, then let the next layout settle before notifying via afterScroll.
if (alignTopPosition != RecyclerView.NO_POSITION && !alignTopCorrected) {
val target = findViewByPosition(alignTopPosition)
if (target != null) {
alignTopCorrected = true
if (getDecoratedTop(target) != alignTopInset) {
val correctedOffset = (height - paddingBottom) - alignTopInset - getDecoratedMeasuredHeight(target)
super.scrollToPositionWithOffset(alignTopPosition, correctedOffset)
return
}
}
}
afterScroll?.invoke()
afterScroll = null
alignTopPosition = RecyclerView.NO_POSITION
}
companion object {
@@ -13,7 +13,8 @@ object AppCapabilities {
storage = storageCapable,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = true
)
}
}
@@ -33,8 +33,10 @@ import net.zetetic.database.Logger;
import org.conscrypt.ConscryptSignal;
import org.greenrobot.eventbus.EventBus;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.AppForegroundObserver;
import org.signal.core.util.DiskUtil;
import org.signal.core.util.MemoryTracker;
import org.signal.core.util.Util;
import org.signal.core.util.concurrent.AnrDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
@@ -51,6 +53,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -59,6 +62,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
import org.thoughtcrime.securesms.glide.SignalGlideComponents;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
@@ -75,6 +79,7 @@ import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
import org.thoughtcrime.securesms.jobs.InAppPaymentAuthCheckJob;
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.jobs.MessageSendLogCleanupJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
@@ -83,7 +88,6 @@ import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -107,10 +111,8 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
@@ -121,7 +123,6 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
@@ -229,7 +230,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
.addPostRender(MessageSendLogCleanupJob::enqueue)
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
@@ -277,7 +278,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
checkFreeDiskSpace();
MemoryTracker.start();
BackupSubscriptionCheckJob.enqueueIfAble();
CheckKeyTransparencyJob.enqueueIfNecessary(true);
CheckKeyTransparencyJob.enqueueIfNecessary(true, false);
AppDependencies.getAuthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
AppDependencies.getUnauthWebSocket().registerKeepAliveToken(SignalWebSocket.FOREGROUND_KEEPALIVE);
@@ -420,6 +421,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
Environment.IS_LINK_AND_SYNC_AVAILABLE,
null,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
@@ -281,7 +281,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts
fixedContacts,
false
)
).get(ContactSearchViewModel.class);
@@ -599,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;
}
@@ -561,6 +561,7 @@ class MainActivity :
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
scope.launch {
paneExpansionState.animateTo(listOnlyAnchor)
}
@@ -1052,6 +1053,13 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
if (!isTrustedConversationIntent(intent)) {
Log.w(TAG, "Received a conversation intent through an exported entry point. Ignoring its extras.")
intent.action = null
setIntent(intent)
return
}
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
@@ -1059,6 +1067,14 @@ class MainActivity :
}
}
/**
* While MainActivity isn't exporting, we have launcher aliases that are, so we verify that someone isn't launching us through those befre
* respecting various intent attributes.
*/
private fun isTrustedConversationIntent(intent: Intent): Boolean {
return intent.component?.className == MainActivity::class.java.name
}
private fun handleGroupLinkInIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
@@ -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?
)
}
@@ -61,21 +61,36 @@ object ApkUpdateInstaller {
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!userInitiated && !shouldAutoUpdate()) {
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppForegroundObserver.isForegrounded()}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
try {
installApk(context, downloadId, userInitiated)
context
.getDownloadManager()
.openDownloadedFile(downloadId)
.use { parcelFileDescriptor ->
val stream = FileInputStream(parcelFileDescriptor.fileDescriptor)
if (!MessageDigest.isEqual(FileUtils.getFileDigest(stream), digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
stream.channel.position(0)
installApk(context, downloadId, stream, userInitiated)
}
} catch (e: IOException) {
Log.w(TAG, "Hit IOException when trying to install APK!", e)
SignalStore.apkUpdate.clearDownloadAttributes()
@@ -88,17 +103,13 @@ object ApkUpdateInstaller {
}
@Throws(IOException::class, SecurityException::class)
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
if (apkInputStream == null) {
Log.w(TAG, "Could not open download APK input stream!")
return
}
private fun installApk(context: Context, downloadId: Long, apkInputStream: InputStream, userInitiated: Boolean) {
Log.d(TAG, "Beginning APK install...")
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
// Reject the session if the APK's declared package name doesn't match ours.
setAppPackageName(context.packageName)
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
// This lets us skip the system-generated notification.
if (Build.VERSION.SDK_INT >= 31) {
@@ -133,15 +144,6 @@ object ApkUpdateInstaller {
session.commit(installerPendingIntent.intentSender)
}
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = context.getDownloadManager().query(query)
@@ -31,7 +31,7 @@ fun Attachment.toAttachmentPointer(context: Context): AttachmentPointer? {
}
try {
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
var attachmentWidth = attachment.width
var attachmentHeight = attachment.height
@@ -41,12 +41,16 @@ enum class Cdn(private val value: Int) {
}
fun fromCdnNumber(cdnNumber: Int): Cdn {
return fromCdnNumberOrNull(cdnNumber) ?: throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
}
fun fromCdnNumberOrNull(cdnNumber: Int): Cdn? {
return when (cdnNumber) {
-1 -> S3
0 -> CDN_0
2 -> CDN_2
3 -> CDN_3
else -> throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
else -> null
}
}
}
@@ -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
@@ -5,6 +5,7 @@ import android.os.Parcel
import androidx.annotation.VisibleForTesting
import org.signal.blurhash.BlurHash
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.whispersystems.signalservice.api.InvalidMessageStructureException
@@ -76,6 +77,8 @@ class PointerAttachment : Attachment {
override val thumbnailUri: Uri? = null
companion object {
private val TAG = Log.tag(PointerAttachment::class)
@JvmStatic
fun forPointers(pointers: Optional<List<SignalServiceAttachment>>): List<Attachment> {
if (!pointers.isPresent) {
@@ -102,6 +105,13 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdnNumber = pointer.get().asPointer().cdnNumber
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered an attachment pointer with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
val encodedKey: String? = pointer.get().asPointer().key?.let { Base64.encodeWithPadding(it) }
return Optional.of(
@@ -110,7 +120,7 @@ class PointerAttachment : Attachment {
transferState = transferState,
size = pointer.get().asPointer().size.orElse(0).toLong(),
fileName = pointer.get().asPointer().fileName.orElse(null),
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
cdn = cdn,
location = pointer.get().asPointer().remoteId.toString(),
key = encodedKey,
iv = null,
@@ -145,7 +155,13 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
val cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0
val cdn = Cdn.fromCdnNumberOrNull(cdnNumber)
if (cdn == null) {
Log.w(TAG, "Encountered a quote thumbnail with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return Optional.empty()
}
if (cdn == Cdn.S3) {
return Optional.empty()
}
@@ -7,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
@@ -38,8 +38,8 @@ object AvatarPickerStorage {
.getAllAvatars()
.filterIsInstance<Avatar.Photo>()
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.map { it.name }
val inDatabaseFileNames = photoAvatars.mapTo(mutableSetOf()) { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.mapTo(mutableSetOf()) { it.name }
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
@@ -6,20 +6,24 @@
package org.thoughtcrime.securesms.backup
import androidx.annotation.WorkerThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import org.signal.core.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
@@ -46,6 +50,8 @@ object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
@@ -61,7 +67,7 @@ object ArchiveUploadProgress {
/**
* Observe this to get updates on the current upload progress.
*/
val progress: Flow<ArchiveUploadProgressState> = _progress
val progress: SharedFlow<ArchiveUploadProgressState> = _progress
.throttleLatest(500.milliseconds) {
uploadProgress.state == ArchiveUploadProgressState.State.None ||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
@@ -114,6 +120,11 @@ object ArchiveUploadProgress {
}
.onStart { emit(uploadProgress) }
.flowOn(Dispatchers.IO)
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
init {
_progress.tryEmit(Unit)
}
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
@@ -11,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;
@@ -19,7 +19,7 @@ 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;
@@ -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
@@ -31,6 +31,9 @@ import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -157,6 +160,22 @@ object ArchiveRestoreProgress {
update()
}
/**
* Self-heal hook for restores that appear active (banner showing, media still remaining) but have no jobs left actually working on them.
*/
fun checkForStalledRestore() {
SignalExecutors.BOUNDED.execute {
val stalled = SignalStore.backup.restoreState.isMediaRestoreOperation &&
SignalDatabase.attachments.getRemainingRestorableAttachmentSize() > 0L &&
AppDependencies.jobManager.areFactoriesEmpty(setOf(RestoreAttachmentJob.KEY, RestoreLocalAttachmentJob.KEY, CheckRestoreMediaLeftJob.KEY))
if (stalled) {
Log.w(TAG, "Detected a stalled media restore with no active jobs. Enqueueing a check job to recover.")
CheckRestoreMediaLeftJob.enqueueStalledRecoveryCheck()
}
}
}
fun clearLocalRestoreDirectoryError() {
SignalStore.backup.localRestoreDirectoryError = false
update()
@@ -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
@@ -72,9 +73,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
@@ -1465,6 +1464,7 @@ object BackupRepository {
}
SignalDatabase.remappedRecords.clearCache()
SignalDatabase.remappedRecords.trimStaleMappings()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()
@@ -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
/**
@@ -123,7 +123,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
throw InvalidAttachmentException("empty content id")
}
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
SignalServiceAttachmentRemoteId.from(remoteLocation, cdn.cdnNumber) to cdn.cdnNumber
}
val key = Base64.decode(remoteKey)
@@ -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? {
@@ -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
@@ -435,7 +435,7 @@ class ChatItemArchiveExporter(
else -> {
val attachments = extraData.attachmentsById[record.id]
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker && !dbAttachment.quote }
if (sticker?.stickerLocator != null) {
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id], exportState = exportState)
@@ -852,8 +852,8 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
PaymentNotification()
} else {
PaymentNotification(
amountMob = payment.amount.serializeAmountString(),
feeMob = payment.fee.serializeAmountString(),
amountMob = payment.amount.requireMobileCoin().amountDecimalString,
feeMob = payment.fee.requireMobileCoin().amountDecimalString,
note = payment.note.takeUnless { it.isEmpty() },
transactionDetails = payment.toRemoteTransactionDetails()
)
@@ -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
@@ -60,7 +60,6 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
@@ -85,7 +84,7 @@ import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.MessageUtil
import org.whispersystems.signalservice.api.payments.Money
import org.whispersystems.signalservice.internal.push.DataMessage
import java.math.BigInteger
import java.math.BigDecimal
import java.sql.SQLException
import java.util.Optional
import java.util.UUID
@@ -1064,8 +1063,8 @@ class ChatItemArchiveImporter(
private fun ContentValues.addPaymentTombstoneNoMetadata(paymentNotification: PaymentNotification) {
put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or MessageTypes.SPECIAL_TYPE_PAYMENTS_TOMBSTONE)
val amount = tryParseCryptoValue(paymentNotification.amountMob)
val fee = tryParseCryptoValue(paymentNotification.feeMob)
val amount = paymentNotification.amountMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
val fee = paymentNotification.feeMob?.tryParseMoney()?.let { CryptoValueUtil.moneyToCryptoValue(it) }
put(
MessageTable.MESSAGE_EXTRAS,
MessageExtras(
@@ -1119,26 +1118,15 @@ class ChatItemArchiveImporter(
return null
}
val amountCryptoValue = tryParseCryptoValue(this)
return if (amountCryptoValue != null) {
CryptoValueUtil.cryptoValueToMoney(amountCryptoValue)
} else {
return try {
Money.mobileCoin(BigDecimal(this))
} catch (e: NumberFormatException) {
null
} catch (e: ArithmeticException) {
null
}
}
private fun tryParseCryptoValue(bigIntegerString: String?): CryptoValue? {
if (bigIntegerString == null) {
return null
}
val amount = try {
BigInteger(bigIntegerString).toString()
} catch (e: NumberFormatException) {
return null
}
return CryptoValue(mobileCoinValue = CryptoValue.MobileCoinValue(picoMobileCoin = amount))
}
private fun ContentValues.addQuote(quote: Quote) {
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
this.put(MessageTable.QUOTE_AUTHOR, importState.requireLocalRecipientId(quote.authorId).serialize())
@@ -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,11 @@ 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.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
@@ -59,11 +60,15 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Util
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
@@ -120,6 +125,7 @@ fun MessageBackupsKeyRecordScreen(
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
@@ -145,6 +151,38 @@ fun MessageBackupsKeyRecordScreen(
RecordScreenBackHandler()
}
var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) }
if (displayRecoveryKeyCopyWarning) {
val context = LocalContext.current
val url = stringResource(R.string.recovery_key_phishing_support_url)
val events: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.GotItClick -> {
onCopyToClipboardClick(backupKeyString)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
CommunicationActions.openBrowserLink(context, url)
displayRecoveryKeyCopyWarning = false
}
RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported")
RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported")
}
}
ModalBottomSheet(
onDismissRequest = { displayRecoveryKeyCopyWarning = false },
dragHandle = { BottomSheets.Handle() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = events
)
}
}
Scaffolds.Settings(
title = "",
navigationIcon = SignalIcons.ArrowStart.imageVector,
@@ -227,7 +265,13 @@ fun MessageBackupsKeyRecordScreen(
item {
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
onClick = {
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
displayRecoveryKeyCopyWarning = true
} else {
onCopyToClipboardClick(backupKeyString)
}
}
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
@@ -0,0 +1,35 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import org.signal.core.models.AccountEntropyPool
/**
* Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key).
*
* We scan anywhere within the text and try to match the key in as many forms as possible:
* upper/lowercase, with or without grouping spaces, and with or without the display characters
* (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than
* just the AEP shape) avoids false positives on any 64-character in-alphabet string.
*/
object RecoveryKeyDetector {
/**
* @param text the text to scan
* @param recoveryKey the user's own recovery key, or null if they don't have one yet
* @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when
* [recoveryKey] is null, so callers can bypass the check entirely for users without a key.
*/
fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean {
if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) {
return false
}
val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase()
return normalized.contains(recoveryKey.value)
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Wires this [ComposeText] so that pasting the user's own recovery key first shows
* [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the
* user explicitly confirms via that warning.
*
* Must be called once the [host]'s view has been created, as it registers a fragment result
* listener scoped to the host's view lifecycle.
*
* @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the
* keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use
* this to suppress that behavior while the warning is up.
* @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if
* any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input.
*/
fun ComposeText.guardAgainstRecoveryKeyPaste(
host: Fragment,
onWarningShown: () -> Unit = {},
onWarningDismissed: () -> Unit = {}
) {
var pendingPaste: CharSequence? = null
host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle ->
if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) {
pendingPaste?.let { insertText(it) }
}
pendingPaste = null
onWarningDismissed()
}
setOnPasteListener { pasteText ->
if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) {
pendingPaste = pasteText
onWarningShown()
RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null)
true
} else {
false
}
}
}
@@ -0,0 +1,78 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import android.content.DialogInterface
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyPasteWarningFragment.Companion.REQUEST_KEY
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user
* attempts to paste their recovery key into the input field.
*
* A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean
* indicating whether the user chose to proceed with the paste. The host can rely on this firing for
* every dismissal path (paste, decline, or cancel) to restore its own state.
*/
class RecoveryKeyPasteWarningFragment : ComposeBottomSheetDialogFragment() {
companion object {
const val REQUEST_KEY = "recovery_key_request"
}
private var shouldPaste = false
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
putBoolean(REQUEST_KEY, shouldPaste)
}
)
super.onDismiss(dialog)
}
@Composable
override fun SheetContent() {
val context = LocalContext.current
val url = stringResource(R.string.recovery_key_phishing_support_url)
val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = {
when (it) {
RecoveryKeyWarningSheetEvent.DoNotShareClick -> {
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.GotItClick -> {
error("Not supported for paste")
}
RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
CommunicationActions.openBrowserLink(context, url)
dismissAllowingStateLoss()
}
RecoveryKeyWarningSheetEvent.ShareKeyClick -> {
shouldPaste = true
dismissAllowingStateLoss()
}
}
}
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = eventHandler
)
}
}
@@ -0,0 +1,172 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun RecoveryKeyWarningSheetContent(
clipStage: ClipStage,
events: (RecoveryKeyWarningSheetEvent) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.horizontalGutters(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier
.padding(top = 20.dp, bottom = 16.dp)
.size(80.dp)
.background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape)
.padding(20.dp)
)
Text(
text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 12.dp)
)
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you))
}
append(" ")
append(stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond))
if (clipStage == ClipStage.PASTE) {
append(" ")
withLink(
link = LinkAnnotation.Clickable(
tag = "learn-more",
styles = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary
)
)
) {
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
}
) {
append(stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more_period))
}
}
},
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 75.dp)
)
when (clipStage) {
ClipStage.COPY -> CopyActionButtons(events = events)
ClipStage.PASTE -> PasteActionButtons(events = events)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
@Composable
fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.GotItClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it))
}
TextButton(onClick = {
events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more))
}
}
@Composable
fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
Buttons.LargeTonal(onClick = {
events(RecoveryKeyWarningSheetEvent.DoNotShareClick)
}) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key))
}
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
onClick = {
events(RecoveryKeyWarningSheetEvent.ShareKeyClick)
}
) {
Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key))
}
}
enum class ClipStage {
COPY,
PASTE
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentCopyPreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.COPY,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@DayNightPreviews
@Composable
private fun RecoveryKeyWarningSheetContentPastePreview() {
Previews.BottomSheetPreview {
RecoveryKeyWarningSheetContent(
clipStage = ClipStage.PASTE,
events = {},
modifier = Modifier.fillMaxSize()
)
}
}
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.warning
sealed interface RecoveryKeyWarningSheetEvent {
data object DoNotShareClick : RecoveryKeyWarningSheetEvent
data object ShareKeyClick : RecoveryKeyWarningSheetEvent
data object GotItClick : RecoveryKeyWarningSheetEvent
data object LearnMoreClick : RecoveryKeyWarningSheetEvent
}
@@ -11,6 +11,7 @@ import org.signal.archive.proto.FilePointer
import org.signal.core.util.Base64
import org.signal.core.util.UuidUtil
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.core.util.orNull
import org.signal.libsignal.usernames.BaseUsernameException
@@ -32,6 +33,8 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import java.util.Optional
import org.signal.archive.proto.AvatarColor as RemoteAvatarColor
private const val TAG = "ArchiveConverter"
/**
* Converts a [FilePointer] to a local [Attachment] object for inserting into the database.
*/
@@ -58,10 +61,16 @@ fun FilePointer?.toLocalAttachment(
return when (attachmentType) {
AttachmentType.ARCHIVE -> {
val cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber
if (Cdn.fromCdnNumberOrNull(cdnNumber) == null) {
Log.w(TAG, "Encountered an archived attachment with an unsupported CDN number ($cdnNumber). Skipping attachment.")
return null
}
ArchivedAttachment(
contentType = contentType,
size = locatorInfo.size.toLong(),
cdn = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
cdn = cdnNumber,
uploadTimestamp = locatorInfo.transitTierUploadTimestamp ?: 0,
key = locatorInfo.key.toByteArray(),
cdnKey = locatorInfo.transitCdnKey?.nullIfBlank(),
@@ -87,7 +96,7 @@ fun FilePointer?.toLocalAttachment(
AttachmentType.TRANSIT -> {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!),
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!, locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber),
contentType = contentType,
key = locatorInfo.key.toByteArray(),
size = Optional.ofNullable(locatorInfo.size),
@@ -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
@@ -306,7 +306,5 @@ class GiftFlowConfirmationFragment :
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Not supported for gifts")
override fun exitCheckoutFlow() {
requireActivity().finishAfterTransition()
}
override fun exitCheckoutFlow() = Unit
}
@@ -4,7 +4,6 @@ import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.navigation.fragment.findNavController
@@ -19,29 +18,25 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigur
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import kotlin.getValue
/**
* Allows the user to select a recipient to send a gift to.
*/
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
private val viewModel: GiftFlowViewModel by activityViewModel {
GiftFlowViewModel()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(
R.id.multiselect_container,
R.id.fragment_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
multiShareArgs = emptyList(),
title = R.string.GiftFlowRecipientSelectionFragment__choose_recipient,
forceDisableAddMessage = true,
selectSingleRecipient = true
)
@@ -79,6 +74,10 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
override fun exitFlow() = Unit
override fun navigateUp() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
@@ -25,6 +26,7 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
override val dataFlow: Flow<ArchiveRestoreProgressState> by lazy {
ArchiveRestoreProgress
.stateFlow
.onStart { ArchiveRestoreProgress.checkForStalledRestore() }
.filter {
it.restoreStatus != RestoreStatus.NONE && (it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED)
}
@@ -21,6 +21,7 @@ import com.bumptech.glide.RequestManager;
import org.signal.core.ui.view.Stub;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
@@ -263,7 +264,7 @@ public class AlbumThumbnailView extends FrameLayout {
}
private void showSlides(@NonNull RequestManager requestManager, @NonNull List<Slide> slides) {
boolean showControls = TransferControlView.containsPlayableSlides(slides);
boolean showControls = TransferControls.containsPlayableSlides(slides);
setSlide(requestManager, slides.get(0), R.id.album_cell_1, showControls);
setSlide(requestManager, slides.get(1), R.id.album_cell_2, showControls);
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components;
import android.content.ClipData;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
@@ -19,6 +20,7 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
@@ -26,6 +28,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import org.signal.core.util.ServiceUtil;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
@@ -68,6 +71,7 @@ public class ComposeText extends EmojiEditText {
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
@Nullable private StylingChangedListener stylingChangedListener;
@Nullable private OnPasteListener onPasteListener;
public ComposeText(Context context) {
super(context);
@@ -213,6 +217,41 @@ public class ComposeText extends EmojiEditText {
stylingChangedListener = listener;
}
public void setOnPasteListener(@Nullable OnPasteListener listener) {
onPasteListener = listener;
}
/**
* Inserts the given text at the current selection (replacing any selected text), as if pasted.
* This goes directly through the underlying {@link Editable}, so it does not pass through the
* {@link OnPasteListener}. Used to complete a paste the listener previously intercepted, replaying
* the exact text that was intercepted rather than re-reading the clipboard the intercepted text
* may have come from an IME suggestion (e.g. the keyboard's clipboard chip) that is not the
* current clipboard contents.
*/
public void insertText(@NonNull CharSequence text) {
Editable editable = getText();
if (editable == null) {
return;
}
int selectionStart = getSelectionStart();
int selectionEnd = getSelectionEnd();
int start;
int end;
if (selectionStart < 0 || selectionEnd < 0) {
start = editable.length();
end = editable.length();
} else {
start = Math.min(selectionStart, selectionEnd);
end = Math.max(selectionStart, selectionEnd);
}
editable.replace(start, end, text);
setSelection(start + text.length());
}
private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
@@ -242,7 +281,19 @@ public class ComposeText extends EmojiEditText {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
return inputConnection;
if (inputConnection == null) {
return null;
}
return new InputConnectionWrapper(inputConnection, true) {
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
if (onPasteListener != null && text != null && onPasteListener.onPaste(text)) {
return true;
}
return super.commitText(text, newCursorPosition);
}
};
}
public boolean hasMentions() {
@@ -479,6 +530,20 @@ public class ComposeText extends EmojiEditText {
return true;
}
@Override
public boolean onTextContextMenuItem(int id) {
if ((id == android.R.id.paste || id == android.R.id.pasteAsPlainText) && onPasteListener != null) {
ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip();
CharSequence pasteText = clipData != null && clipData.getItemCount() > 0 ? clipData.getItemAt(0).coerceToText(getContext()) : null;
if (onPasteListener.onPaste(pasteText)) {
return true;
}
}
return super.onTextContextMenuItem(id);
}
/**
* Return true if we think the user may be inputting a time.
*/
@@ -576,4 +641,15 @@ public class ComposeText extends EmojiEditText {
public interface StylingChangedListener {
void onStylingChanged();
}
public interface OnPasteListener {
/**
* Invoked before a paste is applied to the field, giving an observer the chance to intercept it.
*
* @param pasteText the text currently on the clipboard, or {@code null} if it could not be read
* @return true to consume the paste (the listener will handle it, e.g. by prompting the user),
* or false to let the paste proceed normally
*/
boolean onPaste(@Nullable CharSequence pasteText);
}
}
@@ -38,10 +38,12 @@ import com.bumptech.glide.RequestManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.ui.view.Stub;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SettableFuture;
import org.signal.core.util.logging.Log;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.animation.AnimationStartListener;
@@ -63,7 +65,6 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
@@ -91,32 +92,33 @@ public class InputPanel extends ConstraintLayout
private static final long QUOTE_REVEAL_DURATION_MILLIS = 150;
private static final int FADE_TIME = 150;
private RecyclerView stickerSuggestion;
private QuoteView quoteView;
private LinkPreviewView linkPreview;
private EmojiToggle mediaKeyboard;
private ComposeText composeText;
private ImageButton quickCameraToggle;
private ImageButton quickAudioToggle;
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private View recordingContainer;
private View recordLockCancel;
private View composeContainer;
private View editMessageCancel;
private ImageView editMessageThumbnail;
private View editMessageTitle;
private FrameLayout composeTextContainer;
private RecyclerView stickerSuggestion;
private Stub<QuoteView> quoteViewStub;
private Stub<LinkPreviewView> linkPreviewStub;
private EmojiToggle mediaKeyboard;
private ComposeText composeText;
private ImageButton quickCameraToggle;
private ImageButton quickAudioToggle;
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private View recordingContainer;
private View recordLockCancel;
private View composeContainer;
private View editMessageCancel;
private ImageView editMessageThumbnail;
private View editMessageTitle;
private FrameLayout composeTextContainer;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private ValueAnimator quoteAnimator;
private ValueAnimator editMessageAnimator;
private VoiceNoteDraftView voiceNoteDraftView;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private ValueAnimator quoteAnimator;
private ValueAnimator editMessageAnimator;
private Stub<VoiceNoteDraftView> voiceNoteDraftViewStub;
private @Nullable Listener listener;
private boolean emojiVisible;
private boolean wallpaperEnabled;
private boolean hideForMessageRequestState;
private boolean hideForGroupState;
@@ -127,6 +129,12 @@ public class InputPanel extends ConstraintLayout
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
private MessageRecord messageToEdit;
private final Observer<VoiceNotePlaybackState> playbackStateObserverProxy = state -> {
if (voiceNoteDraftViewStub.resolved()) {
voiceNoteDraftViewStub.get().getPlaybackStateObserver().onChanged(state);
}
};
public InputPanel(Context context) {
super(context);
}
@@ -143,12 +151,10 @@ public class InputPanel extends ConstraintLayout
public void onFinishInflate() {
super.onFinishInflate();
View quoteDismiss = findViewById(R.id.quote_dismiss_stub);
this.composeContainer = findViewById(R.id.compose_bubble);
this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);
this.quoteView = findViewById(R.id.quote_view);
this.linkPreview = findViewById(R.id.link_preview);
this.quoteViewStub = new Stub<>(findViewById(R.id.quote_view));
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview));
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor);
this.composeTextContainer = findViewById(R.id.embedded_text_editor_container);
@@ -158,7 +164,7 @@ public class InputPanel extends ConstraintLayout
this.sendButton = findViewById(R.id.send_button);
this.recordingContainer = findViewById(R.id.recording_container);
this.recordLockCancel = findViewById(R.id.record_cancel);
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
this.voiceNoteDraftViewStub = new Stub<>(findViewById(R.id.voice_note_draft_view_stub));
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
this.microphoneRecorderView = findViewById(R.id.recorder_view);
this.microphoneRecorderView.setHandler(this);
@@ -175,14 +181,6 @@ public class InputPanel extends ConstraintLayout
mediaKeyboard.setVisibility(View.VISIBLE);
emojiVisible = true;
quoteDismiss.setOnClickListener(v -> clearQuote());
linkPreview.setCloseClickedListener(() -> {
if (listener != null) {
listener.onLinkPreviewCanceled();
}
});
stickerSuggestionAdapter = new ConversationStickerSuggestionAdapter(Glide.with(this), this);
stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
@@ -197,7 +195,6 @@ public class InputPanel extends ConstraintLayout
this.listener = listener;
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
voiceNoteDraftView.setListener(listener);
if (Camera.getNumberOfCameras() > 0) {
quickCameraToggle.setOnClickListener(v -> listener.onQuickCameraToggleClicked());
@@ -214,34 +211,35 @@ public class InputPanel extends ConstraintLayout
@NonNull SlideDeck attachments,
@NonNull QuoteModel.Type quoteType)
{
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true, null);
QuoteView quoteView = requireQuoteView();
quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true, null);
if (listener != null) {
this.quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
}
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
: 0;
int originalHeight = quoteView.getVisibility() == VISIBLE ? quoteView.getMeasuredHeight() : 0;
this.quoteView.setVisibility(VISIBLE);
quoteView.setVisibility(VISIBLE);
int maxWidth = composeContainer.getWidth();
if (quoteView.getLayoutParams() instanceof MarginLayoutParams) {
MarginLayoutParams layoutParams = (MarginLayoutParams) quoteView.getLayoutParams();
maxWidth -= layoutParams.leftMargin + layoutParams.rightMargin;
}
this.quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
if (quoteAnimator != null) {
quoteAnimator.cancel();
}
quoteAnimator = createHeightAnimator(quoteView, originalHeight, this.quoteView.getMeasuredHeight(), null);
quoteAnimator = createHeightAnimator(quoteView, originalHeight, quoteView.getMeasuredHeight(), null);
quoteAnimator.start();
if (this.linkPreview.getVisibility() == View.VISIBLE) {
if (linkPreviewStub.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
}
if (listener != null) {
@@ -250,6 +248,12 @@ public class InputPanel extends ConstraintLayout
}
public void clearQuote() {
if (!quoteViewStub.resolved()) {
return;
}
QuoteView quoteView = quoteViewStub.get();
if (quoteAnimator != null) {
quoteAnimator.cancel();
}
@@ -259,9 +263,9 @@ public class InputPanel extends ConstraintLayout
public void onAnimationEnd(Animator animation) {
quoteView.dismiss();
if (linkPreview.getVisibility() == View.VISIBLE) {
if (linkPreviewStub.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_radius);
linkPreview.setCorners(cornerRadius, cornerRadius);
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
}
}
});
@@ -273,6 +277,20 @@ public class InputPanel extends ConstraintLayout
}
}
private @NonNull QuoteView requireQuoteView() {
boolean wasResolved = quoteViewStub.resolved();
QuoteView quoteView = quoteViewStub.get();
if (!wasResolved) {
quoteView.setWallpaperEnabled(wallpaperEnabled);
View quoteDismiss = quoteView.findViewById(R.id.quote_dismiss_stub);
if (quoteDismiss != null) {
quoteDismiss.setOnClickListener(v -> clearQuote());
}
}
return quoteView;
}
private static ValueAnimator createHeightAnimator(@NonNull View view,
int originalHeight,
int finalHeight,
@@ -294,11 +312,12 @@ public class InputPanel extends ConstraintLayout
return animator;
}
public boolean hasSaveableContent() {
return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null;
}
public Optional<QuoteModel> getQuote() {
if (!quoteViewStub.resolved()) {
return Optional.empty();
}
QuoteView quoteView = quoteViewStub.get();
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(),
quoteView.getAuthor().getId(),
@@ -314,41 +333,53 @@ public class InputPanel extends ConstraintLayout
}
public boolean hasLinkPreview() {
return linkPreview.getVisibility() == View.VISIBLE;
return linkPreviewStub.getVisibility() == View.VISIBLE;
}
public void setLinkPreviewLoading() {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLoading();
LinkPreviewView linkPreview = requireLinkPreview();
linkPreview.setVisibility(View.VISIBLE);
linkPreview.setLoading();
}
public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setNoPreview(customError);
LinkPreviewView linkPreview = requireLinkPreview();
linkPreview.setVisibility(View.VISIBLE);
linkPreview.setNoPreview(customError);
}
public void setLinkPreview(@NonNull RequestManager requestManager, @NonNull Optional<LinkPreview> preview) {
if (preview.isPresent()) {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLinkPreview(requestManager, preview.get(), true);
LinkPreviewView linkPreview = requireLinkPreview();
linkPreview.setVisibility(View.VISIBLE);
linkPreview.setLinkPreview(requestManager, preview.get(), true);
} else {
this.linkPreview.setVisibility(View.GONE);
linkPreviewStub.setVisibility(View.GONE);
}
int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius)
: readDimen(R.dimen.message_corner_radius);
if (linkPreviewStub.resolved()) {
int cornerRadius = quoteViewStub.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius) : readDimen(R.dimen.message_corner_radius);
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
}
}
this.linkPreview.setCorners(cornerRadius, cornerRadius);
private @NonNull LinkPreviewView requireLinkPreview() {
boolean wasResolved = linkPreviewStub.resolved();
LinkPreviewView view = linkPreviewStub.get();
if (!wasResolved) {
view.setCloseClickedListener(() -> {
if (listener != null) listener.onLinkPreviewCanceled();
});
}
return view;
}
public void clickOnComposeInput() {
composeText.performClick();
}
public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
this.mediaKeyboard.attach(mediaKeyboard);
}
public void setStickerSuggestions(@NonNull List<StickerRecord> stickers) {
stickerSuggestion.setVisibility(stickers.isEmpty() ? View.GONE : View.VISIBLE);
stickerSuggestionAdapter.setStickers(stickers);
@@ -403,7 +434,10 @@ public class InputPanel extends ConstraintLayout
quickCameraToggle.setColorFilter(iconTint);
composeText.setTextColor(textColor);
composeText.setHintTextColor(textHintColor);
quoteView.setWallpaperEnabled(enabled);
wallpaperEnabled = enabled;
if (quoteViewStub.resolved()) {
quoteViewStub.get().setWallpaperEnabled(enabled);
}
}
public void enterEditModeIfPossible(@NonNull RequestManager requestManager, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft, boolean clearQuote) {
@@ -493,7 +527,9 @@ public class InputPanel extends ConstraintLayout
if (messageToEdit != null) {
composeText.setText("");
messageToEdit = null;
quoteView.setMessageType(QuoteView.MessageType.PREVIEW);
if (quoteViewStub.resolved()) {
quoteViewStub.get().setMessageType(QuoteView.MessageType.PREVIEW);
}
clearQuote();
}
updateEditModeUi();
@@ -647,7 +683,7 @@ public class InputPanel extends ConstraintLayout
}
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
return voiceNoteDraftView.getPlaybackStateObserver();
return playbackStateObserverProxy;
}
public void setEnabled(boolean enabled) {
@@ -666,7 +702,7 @@ public class InputPanel extends ConstraintLayout
future.addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
if (voiceNoteDraftView.getDraft() == null) {
if (!voiceNoteDraftViewStub.resolved() || voiceNoteDraftViewStub.get().getDraft() == null) {
fadeInNormalComposeViews();
}
}
@@ -680,10 +716,6 @@ public class InputPanel extends ConstraintLayout
mediaKeyboard.setToMedia();
}
public void setToIme() {
mediaKeyboard.setToIme();
}
@Override
public void onKeyEvent(KeyEvent keyEvent) {
composeText.dispatchKeyEvent(keyEvent);
@@ -715,20 +747,35 @@ public class InputPanel extends ConstraintLayout
public void setVoiceNoteDraft(@Nullable DraftTable.Draft voiceNoteDraft) {
if (voiceNoteDraft != null) {
VoiceNoteDraftView voiceNoteDraftView = requireVoiceNoteDraft();
voiceNoteDraftView.setDraft(voiceNoteDraft);
voiceNoteDraftView.setVisibility(VISIBLE);
hideNormalComposeViews();
fadeIn(buttonToggle);
buttonToggle.displayQuick(sendButton);
} else {
voiceNoteDraftView.clearDraft();
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
if (voiceNoteDraftViewStub.resolved()) {
VoiceNoteDraftView voiceNoteDraftView = voiceNoteDraftViewStub.get();
voiceNoteDraftView.clearDraft();
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
}
fadeInNormalComposeViews();
}
}
public @Nullable DraftTable.Draft getVoiceNoteDraft() {
return voiceNoteDraftView.getDraft();
if (!voiceNoteDraftViewStub.resolved()) return null;
return voiceNoteDraftViewStub.get().getDraft();
}
private @NonNull VoiceNoteDraftView requireVoiceNoteDraft() {
boolean wasResolved = voiceNoteDraftViewStub.resolved();
VoiceNoteDraftView voiceNoteDraftView = voiceNoteDraftViewStub.get();
if (!wasResolved) {
voiceNoteDraftView.setListener(listener);
}
return voiceNoteDraftView;
}
private void hideNormalComposeViews() {
@@ -4,8 +4,6 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
@@ -43,9 +41,6 @@ import okhttp3.HttpUrl;
*/
public class LinkPreviewView extends FrameLayout {
private static final String STATE_ROOT = "linkPreviewView.state.root";
private static final String STATE_STATE = "linkPreviewView.state.state";
private static final int TYPE_CONVERSATION = 0;
private static final int TYPE_COMPOSE = 1;
@@ -114,30 +109,6 @@ public class LinkPreviewView extends FrameLayout {
setWillNotDraw(false);
}
@Override
protected @NonNull Parcelable onSaveInstanceState() {
Parcelable root = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(STATE_ROOT, root);
bundle.putParcelable(STATE_STATE, thumbnailState);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Parcelable root = ((Bundle) state).getParcelable(STATE_ROOT);
thumbnailState = ((Bundle) state).getParcelable(STATE_STATE);
thumbnailState.applyState(thumbnail);
super.onRestoreInstanceState(root);
} else {
super.onRestoreInstanceState(state);
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
@@ -251,7 +222,7 @@ public class LinkPreviewView extends FrameLayout {
thumbnailState.applyState(thumbnail);
} else {
cornerMask.setRadii(topStart, topEnd, 0, 0);
thumbnailState.copy(
thumbnailState = thumbnailState.copy(
topStart,
defaultRadius,
defaultRadius,
@@ -434,6 +434,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
if (TextUtils.isEmpty(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentVideoOVerlayStub.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
@@ -5,7 +5,6 @@ import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
@@ -69,7 +68,7 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
initialize(attrs);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
@RequiresApi(api = 21)
public SharedContactView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(attrs);
@@ -37,7 +37,7 @@ class SignalProgressDialog private constructor(
var progress: Int
get() = progressBar.progress
set(value) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
set(value) = if (Build.VERSION.SDK_INT >= 24) {
progressBar.setProgress(value, true)
} else {
progressBar.setProgress(value)
@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControls;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.glide.targets.GlideBitmapListeningTarget;
import org.thoughtcrime.securesms.glide.targets.GlideDrawableListeningTarget;
@@ -384,7 +385,7 @@ public class ThumbnailView extends FrameLayout {
}
transferControlViewStub.get().setSlides(List.of(slide));
}
int transferState = TransferControlView.getTransferState(List.of(slide));
int transferState = TransferControls.getTransferState(List.of(slide));
boolean isOffloadedImage = (transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && MediaUtil.isImageType(slide.getContentType())) && AttachmentUtil.isRestoreOnOpenPermitted(getContext(), slide.asAttachment());
if (!showControls ||
@@ -48,7 +48,7 @@ class SystemEmojiDrawable(emoji: CharSequence) : Drawable() {
companion object {
private val textPaint: TextPaint = TextPaint()
private fun getStaticLayout(emoji: CharSequence): StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
private fun getStaticLayout(emoji: CharSequence): StaticLayout = if (Build.VERSION.SDK_INT >= 23) {
StaticLayout.Builder.obtain(emoji, 0, emoji.length, textPaint, Int.MAX_VALUE).build()
} else {
@Suppress("DEPRECATION")
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.registration;
import android.content.Context;
import android.graphics.PorterDuff;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
@@ -49,7 +48,7 @@ public class VerificationPinKeyboard extends FrameLayout {
initialize();
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
@RequiresApi(api = 21)
public VerificationPinKeyboard(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize();
@@ -25,12 +25,12 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
setContentView(R.layout.dsl_settings_activity)
if (savedInstanceState == null) {
val navGraphId = intent.getIntExtra(ARG_NAV_GRAPH, -1)
val navGraphId = resolveNavGraphId()
if (navGraphId == -1) {
throw IllegalStateException("No navgraph id was passed to activity")
}
val fragment: NavHostFragment = NavHostFragment.create(navGraphId, intent.getBundleExtra(ARG_START_BUNDLE))
val fragment: NavHostFragment = NavHostFragment.create(navGraphId, resolveStartBundle())
supportFragmentManager.beginTransaction()
.replace(R.id.nav_host_fragment, fragment)
@@ -64,6 +64,10 @@ open class DSLSettingsActivity : PassphraseRequiredActivity() {
protected open fun onWillFinish() {}
protected open fun resolveNavGraphId(): Int = intent.getIntExtra(ARG_NAV_GRAPH, -1)
protected open fun resolveStartBundle(): Bundle? = intent.getBundleExtra(ARG_START_BUNDLE)
companion object {
const val ARG_NAV_GRAPH = "nav_graph"
const val ARG_START_BUNDLE = "start_bundle"
@@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.components.settings.app
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Process
import androidx.navigation.NavDirections
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
@@ -34,20 +37,21 @@ private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_crea
class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
private val TAG = Log.tag(AppSettingsActivity::class)
private var wasConfigurationUpdated = false
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
}
super.onCreate(savedInstanceState, ready)
val startingAction: NavDirections? = if (intent?.categories?.contains(NOTIFICATION_CATEGORY) == true) {
AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
} else if (Build.VERSION.SDK_INT >= 34 && getLaunchedFromUid() != Process.myUid()) {
Log.w(TAG, "Settings was launched by an external process. Ignoring starting route.")
null
} else {
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
AppSettingsRoute.Empty -> null
@@ -144,6 +148,10 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
}
}
override fun resolveNavGraphId(): Int = R.navigation.app_settings_with_change_number
override fun resolveStartBundle(): Bundle? = null
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.core.ui.R as CoreUiR
class AppSettingsFragment : ComposeFragment(), Callbacks {
@@ -295,7 +296,7 @@ private fun AppSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(R.drawable.symbol_person_circle_24),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
@@ -305,7 +306,7 @@ private fun AppSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
icon = painterResource(R.drawable.symbol_devices_24),
icon = painterResource(CoreUiR.drawable.symbol_devices_24),
onClick = {
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
},
@@ -242,7 +242,7 @@ private fun BackupsSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups),
icon = ImageVector.vectorResource(R.drawable.symbol_device_phone_24),
icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_device_phone_24),
label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to),
onClick = onOnDeviceBackupsRowClick
)
@@ -6,9 +6,12 @@ import android.content.Context
import android.content.DialogInterface
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@@ -74,8 +77,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
import java.util.UUID
@@ -84,13 +89,14 @@ import kotlin.math.max
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences, R.menu.internal_settings) {
companion object {
private val TAG = Log.tag(InternalSettingsFragment::class.java)
}
private lateinit var viewModel: InternalSettingsViewModel
private var searchMenuItem: MenuItem? = null
private var scrollToPosition: Int = 0
private val layoutManager: LinearLayoutManager?
@@ -107,6 +113,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
scrollToPosition = SignalStore.internal.lastScrollPosition
initializeSearch(view)
setFragmentResultListener(CallQualityBottomSheetFragment.REQUEST_KEY) { _, bundle ->
if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) {
@@ -125,8 +132,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
viewModel = ViewModelProvider(this, factory)[InternalSettingsViewModel::class.java]
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList()) {
if (scrollToPosition != 0) {
val mappingModelList = getConfiguration(it).toMappingModelList()
val filteredList = viewModel.filterPreferences(requireContext(), mappingModelList, it.searchQuery)
adapter.submitList(filteredList) {
if (scrollToPosition != 0 && it.searchQuery.isBlank()) {
layoutManager?.scrollToPositionWithOffset(scrollToPosition, 0)
scrollToPosition = 0
}
@@ -134,6 +144,56 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
}
override fun onToolbarNavigationClicked() {
if (searchMenuItem?.isActionViewExpanded == true) {
searchMenuItem?.collapseActionView()
} else {
super.onToolbarNavigationClicked()
}
}
private fun initializeSearch(view: View) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
searchMenuItem = toolbar.menu.findItem(R.id.menu_search)
val searchView: SearchView = searchMenuItem?.actionView as? SearchView ?: return
val queryListener = object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
searchView.clearFocus()
viewModel.setSearchQuery(query.orEmpty())
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.setSearchQuery(newText.orEmpty())
return true
}
}
searchView.maxWidth = Integer.MAX_VALUE
searchView.queryHint = getString(R.string.CameraContacts__menu_search)
searchMenuItem?.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
searchView.setIncognitoKeyboardEnabled(TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()))
searchView.setOnQueryTextListener(queryListener)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
searchView.setOnQueryTextListener(null)
searchView.setQuery("", false)
viewModel.setSearchQuery("")
return true
}
})
val currentQuery = viewModel.state.value?.searchQuery.orEmpty()
if (currentQuery.isNotBlank() && searchMenuItem?.expandActionView() == true) {
searchView.setQuery(currentQuery, false)
}
}
private fun getConfiguration(state: InternalSettingsState): DSLConfiguration {
return configure {
sectionHeaderPref(DSLSettingsText.from("Account"))
@@ -196,6 +256,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("App Issues"),
summary = DSLSettingsText.from("View recorded app issues, like slow reads and writes."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalIssuesFragment())
}
)
switchPref(
title = DSLSettingsText.from("Disable internal user flag"),
summary = DSLSettingsText.from("Experience life as a non-internal user. Force-stop the app to be an internal user again."),
@@ -207,6 +275,50 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Playgrounds"))
clickPref(
title = DSLSettingsText.from("SQLite Playground"),
summary = DSLSettingsText.from("Run raw SQLite queries."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSqlitePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Backup Playground"),
summary = DSLSettingsText.from("Test backup import/export."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalBackupPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Storage Service Playground"),
summary = DSLSettingsText.from("Test and view storage service stuff."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalStorageServicePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("SVR Playground"),
summary = DSLSettingsText.from("Quickly test various SVR options and error conditions."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSvrPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Data Seeding Playground"),
summary = DSLSettingsText.from("Seed conversations with media files from a folder."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDataSeedingPlaygroundFragment())
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("App UI"))
switchPref(
@@ -255,50 +367,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Playgrounds"))
clickPref(
title = DSLSettingsText.from("SQLite Playground"),
summary = DSLSettingsText.from("Run raw SQLite queries."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSqlitePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Backup Playground"),
summary = DSLSettingsText.from("Test backup import/export."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalBackupPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Storage Service Playground"),
summary = DSLSettingsText.from("Test and view storage service stuff."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalStorageServicePlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("SVR Playground"),
summary = DSLSettingsText.from("Quickly test various SVR options and error conditions."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalSvrPlaygroundFragment())
}
)
clickPref(
title = DSLSettingsText.from("Data Seeding Playground"),
summary = DSLSettingsText.from("Seed conversations with media files from a folder."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDataSeedingPlaygroundFragment())
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Miscellaneous"))
clickPref(
@@ -378,8 +446,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
title = DSLSettingsText.from("Run self-check key transparency"),
summary = DSLSettingsText.from("Automatically enqueues a job to run KT against yourself without waiting for the elapsed time."),
onClick = {
SignalStore.misc.lastKeyTransparencyTime = 0
CheckKeyTransparencyJob.enqueueIfNecessary(addDelay = false)
CheckKeyTransparencyJob.enqueueIfNecessary(addDelay = false, force = true)
}
)
@@ -32,5 +32,6 @@ data class InternalSettingsState(
val forceSplitPane: Boolean,
val forceSinglePane: Boolean,
val useNewMediaActivity: Boolean,
val disableInternalUser: Boolean
val disableInternalUser: Boolean,
val searchQuery: String = ""
)
@@ -1,10 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.internal
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.components.settings.DividerPreference
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.SectionHeaderPreference
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
import org.thoughtcrime.securesms.keyvalue.InternalValues
@@ -12,7 +16,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Locale
class InternalSettingsViewModel(private val repository: InternalSettingsRepository) : ViewModel() {
private val preferenceDataStore = SignalStore.getPreferenceDataStore()
@@ -167,7 +174,47 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
}
fun refresh() {
store.update { getState().copy(emojiVersion = it.emojiVersion) }
store.update { getState().copy(emojiVersion = it.emojiVersion, searchQuery = it.searchQuery) }
}
fun setSearchQuery(query: String) {
store.update {
if (it.searchQuery == query) {
it
} else {
it.copy(searchQuery = query)
}
}
}
fun filterPreferences(context: Context, items: MappingModelList, query: String): MappingModelList {
val normalizedQuery = query.trim().lowercase(Locale.getDefault())
if (normalizedQuery.isBlank()) {
return items
}
val groups = buildSearchGroups(items)
val filtered = MappingModelList()
groups.forEach { group ->
val headerMatches = group.header?.searchableText(context)?.contains(normalizedQuery) == true
val matchingItems = if (headerMatches) {
group.items
} else {
group.items.filter { it.searchableText(context)?.contains(normalizedQuery) == true }
}
if (headerMatches || matchingItems.isNotEmpty()) {
if (filtered.isNotEmpty() && group.divider != null) {
filtered.add(group.divider)
}
group.header?.let { filtered.add(it) }
filtered.addAll(matchingItems)
}
}
return filtered
}
private fun getState() = InternalSettingsState(
@@ -225,6 +272,57 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
private fun buildSearchGroups(items: MappingModelList): List<SearchGroup> {
val groups = mutableListOf<SearchGroup>()
var divider: DividerPreference? = null
var header: SectionHeaderPreference? = null
var groupItems = mutableListOf<MappingModel<*>>()
fun flush() {
if (header != null || groupItems.isNotEmpty()) {
groups.add(SearchGroup(divider, header, groupItems))
}
divider = null
header = null
groupItems = mutableListOf()
}
items.forEach { item ->
when (item) {
is DividerPreference -> {
flush()
divider = item
}
is SectionHeaderPreference -> {
flush()
header = item
}
else -> groupItems.add(item)
}
}
flush()
return groups
}
private fun MappingModel<*>.searchableText(context: Context): String? {
return if (this is PreferenceModel<*>) {
listOfNotNull(title, summary)
.joinToString(separator = " ") { it.resolve(context).toString() }
.lowercase(Locale.getDefault())
} else {
null
}
}
private data class SearchGroup(
val divider: DividerPreference?,
val header: SectionHeaderPreference?,
val items: List<MappingModel<*>>
)
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository)))
@@ -27,6 +27,7 @@ import org.signal.archive.stream.EncryptedBackupReader
import org.signal.archive.stream.EncryptedBackupReader.Companion.MAC_SIZE
import org.signal.core.models.ServiceId
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Hex
import org.signal.core.util.ThreadUtil
import org.signal.core.util.bytes
@@ -39,7 +40,6 @@ import org.signal.core.util.stream.LimitedInputStream
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.NetworkResult
import org.signal.network.api.SvrBApi
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.LocalExportProgress
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.ComposeFragment
class InternalIssuesFragment : ComposeFragment() {
private val viewModel: InternalIssuesViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.onEvent(InternalIssuesScreenEvent.Load)
}
InternalIssuesScreen(
state = state,
onEvent = viewModel::onEvent,
onBack = { findNavController().popBackStack() }
)
}
}
@@ -0,0 +1,344 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.LogDatabase.IssueTable.IssueRecord
import org.thoughtcrime.securesms.database.model.IssuePriority
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InternalIssuesScreen(
state: InternalIssuesState,
onEvent: (InternalIssuesScreenEvent) -> Unit = {},
onBack: () -> Unit = {}
) {
var showFilterSheet by remember { mutableStateOf(false) }
var showSortSheet by remember { mutableStateOf(false) }
var showClearDialog by remember { mutableStateOf(false) }
Scaffolds.Settings(
title = "App Issues",
onNavigationClick = onBack,
navigationIcon = SignalIcons.ArrowStart.imageVector,
snackbarHost = {},
actions = {
if (state.names.isNotEmpty()) {
IconButton(onClick = { showFilterSheet = true }) {
Icon(painter = painterResource(R.drawable.symbol_filter_24), contentDescription = "Filter")
}
IconButton(onClick = { showSortSheet = true }) {
Icon(painter = painterResource(R.drawable.symbol_list_bullet_24), contentDescription = "Sort")
}
IconButton(onClick = { showClearDialog = true }) {
Icon(painter = SignalIcons.Trash.painter, contentDescription = "Clear")
}
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Rows.RadioListRow(
text = "Notification priority threshold",
labels = IssuePriority.entries.map { it.label }.toTypedArray(),
values = IssuePriority.entries.map { it.name }.toTypedArray(),
selectedValue = state.notificationPriority.name,
onSelected = { onEvent(InternalIssuesScreenEvent.SetNotificationPriority(IssuePriority.valueOf(it))) }
)
HorizontalDivider()
if (!state.loading && state.issues.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = if (state.nameFilter != null) "No issues match this filter." else "No issues recorded.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
items(state.issues, key = { it.id }) { issue ->
IssueRow(
issue = issue,
expanded = state.expandedIds.contains(issue.id),
onClick = { onEvent(InternalIssuesScreenEvent.ToggleExpanded(issue.id)) }
)
HorizontalDivider()
}
}
}
}
}
if (showFilterSheet) {
ModalBottomSheet(
onDismissRequest = { showFilterSheet = false },
sheetState = rememberModalBottomSheetState()
) {
SheetTitle("Filter by name")
SelectionRow(
text = "All",
selected = state.nameFilter == null,
onClick = {
onEvent(InternalIssuesScreenEvent.SetNameFilter(null))
showFilterSheet = false
}
)
state.names.forEach { name ->
SelectionRow(
text = name,
selected = state.nameFilter == name,
onClick = {
onEvent(InternalIssuesScreenEvent.SetNameFilter(name))
showFilterSheet = false
}
)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
if (showSortSheet) {
ModalBottomSheet(
onDismissRequest = { showSortSheet = false },
sheetState = rememberModalBottomSheetState()
) {
SheetTitle("Sort by")
IssueSortOrder.entries.forEach { order ->
SelectionRow(
text = order.label,
selected = state.sortOrder == order,
onClick = {
onEvent(InternalIssuesScreenEvent.SetSortOrder(order))
showSortSheet = false
}
)
}
Spacer(modifier = Modifier.size(16.dp))
}
}
if (showClearDialog) {
Dialogs.SimpleAlertDialog(
title = "Clear all issues?",
body = "This will permanently delete all recorded app issues.",
confirm = "Clear",
dismiss = "Cancel",
onConfirm = { onEvent(InternalIssuesScreenEvent.ClearAll) },
onDismiss = { showClearDialog = false }
)
}
}
@Composable
private fun SheetTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
)
}
@Composable
private fun SelectionRow(
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
if (selected) {
Icon(
painter = SignalIcons.Check.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun IssueRow(
issue: IssueRecord,
expanded: Boolean,
onClick: () -> Unit
) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = {
clipboardManager.setText(AnnotatedString(issue.toCopyText()))
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
}
)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = issue.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = issue.priority.label,
style = MaterialTheme.typography.labelMedium,
color = priorityColor(issue.priority),
fontWeight = FontWeight.Bold
)
}
Text(
text = buildString {
append(formatTimestamp(issue.createdAt))
append(" • v")
append(issue.version)
issue.duration?.let { append("${it}ms") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = issue.description,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (expanded) Int.MAX_VALUE else 2
)
if (expanded && !issue.stackTrace.isNullOrBlank()) {
Text(
text = issue.stackTrace,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
private fun IssueRecord.toCopyText(): String {
return buildString {
append(name)
append(" (")
append(priority.label)
append(")\n")
append(formatTimestamp(createdAt))
append(" • v")
append(version)
duration?.let { append("${it}ms") }
append("\n")
append(description)
if (!stackTrace.isNullOrBlank()) {
append("\n")
append(stackTrace)
}
}
}
@Composable
private fun priorityColor(priority: IssuePriority): Color {
return when (priority) {
IssuePriority.HIGH -> MaterialTheme.colorScheme.error
IssuePriority.MEDIUM -> MaterialTheme.colorScheme.tertiary
IssuePriority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
}
}
private fun formatTimestamp(time: Long): String {
return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(time))
}
@DayNightPreviews
@Composable
private fun InternalIssuesScreenPreview() {
Previews.Preview {
InternalIssuesScreen(
state = InternalIssuesState(
loading = false,
names = listOf("Slow Database Read", "Slow Database Write"),
issues = listOf(
IssueRecord(1, System.currentTimeMillis(), "7.42.1", "Slow Database Write", "Took 812ms. query=transaction hold", "java.lang.Throwable\n\tat Foo.bar(Foo.java:1)", IssuePriority.HIGH, 812),
IssueRecord(2, System.currentTimeMillis(), "7.42.1", "Slow Database Read", "Took 1043ms. query=SELECT * FROM message", null, IssuePriority.LOW, 1043)
)
)
)
}
}
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import org.thoughtcrime.securesms.database.LogDatabase.IssueTable.IssueRecord
import org.thoughtcrime.securesms.database.model.IssuePriority
data class InternalIssuesState(
val loading: Boolean = true,
val issues: List<IssueRecord> = emptyList(),
val names: List<String> = emptyList(),
val nameFilter: String? = null,
val sortOrder: IssueSortOrder = IssueSortOrder.CREATED_DESC,
val expandedIds: Set<Long> = emptySet(),
val notificationPriority: IssuePriority = IssuePriority.HIGH
)
enum class IssueSortOrder(val label: String) {
CREATED_DESC("Newest first"),
CREATED_ASC("Oldest first"),
DURATION_DESC("Longest duration"),
DURATION_ASC("Shortest duration"),
PRIORITY_DESC("Highest priority"),
PRIORITY_ASC("Lowest priority")
}
sealed interface InternalIssuesScreenEvent {
data object Load : InternalIssuesScreenEvent
data object ClearAll : InternalIssuesScreenEvent
data class ToggleExpanded(val id: Long) : InternalIssuesScreenEvent
data class SetNotificationPriority(val priority: IssuePriority) : InternalIssuesScreenEvent
data class SetNameFilter(val name: String?) : InternalIssuesScreenEvent
data class SetSortOrder(val order: IssueSortOrder) : InternalIssuesScreenEvent
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.issues
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.LogDatabase.IssueTable.IssueRecord
import org.thoughtcrime.securesms.database.model.IssuePriority
import org.thoughtcrime.securesms.keyvalue.SignalStore
class InternalIssuesViewModel(application: Application) : AndroidViewModel(application) {
private val _state = MutableStateFlow(InternalIssuesState())
val state: StateFlow<InternalIssuesState> = _state.asStateFlow()
private val issues = LogDatabase.getInstance(application).issues
private var allIssues: List<IssueRecord> = emptyList()
fun onEvent(event: InternalIssuesScreenEvent) {
when (event) {
InternalIssuesScreenEvent.Load -> load()
InternalIssuesScreenEvent.ClearAll -> clearAll()
is InternalIssuesScreenEvent.ToggleExpanded -> toggleExpanded(event.id)
is InternalIssuesScreenEvent.SetNotificationPriority -> setNotificationPriority(event.priority)
is InternalIssuesScreenEvent.SetNameFilter -> _state.update { it.copy(nameFilter = event.name).withVisibleIssues() }
is InternalIssuesScreenEvent.SetSortOrder -> _state.update { it.copy(sortOrder = event.order).withVisibleIssues() }
}
}
private fun load() {
viewModelScope.launch {
allIssues = withContext(Dispatchers.IO) { issues.getRecent() }
_state.update { it.copy(loading = false, notificationPriority = SignalStore.internal.issueNotificationPriority).withVisibleIssues() }
}
}
private fun setNotificationPriority(priority: IssuePriority) {
SignalStore.internal.issueNotificationPriority = priority
_state.update { it.copy(notificationPriority = priority) }
}
private fun clearAll() {
viewModelScope.launch {
withContext(Dispatchers.IO) { issues.clear() }
allIssues = emptyList()
_state.update { it.copy(nameFilter = null, expandedIds = emptySet()).withVisibleIssues() }
}
}
private fun toggleExpanded(id: Long) {
_state.update {
val expanded = if (it.expandedIds.contains(id)) it.expandedIds - id else it.expandedIds + id
it.copy(expandedIds = expanded)
}
}
private fun InternalIssuesState.withVisibleIssues(): InternalIssuesState {
val visible = allIssues
.filter { nameFilter == null || it.name == nameFilter }
.sortedWith(sortOrder.comparator())
return copy(issues = visible, names = allIssues.map { it.name }.distinct().sorted())
}
private fun IssueSortOrder.comparator(): Comparator<IssueRecord> {
return when (this) {
IssueSortOrder.CREATED_DESC -> compareByDescending { it.createdAt }
IssueSortOrder.CREATED_ASC -> compareBy { it.createdAt }
IssueSortOrder.DURATION_DESC -> compareByDescending { it.duration ?: Long.MIN_VALUE }
IssueSortOrder.DURATION_ASC -> compareBy { it.duration ?: Long.MAX_VALUE }
IssueSortOrder.PRIORITY_DESC -> compareByDescending { it.priority.value }
IssueSortOrder.PRIORITY_ASC -> compareBy { it.priority.value }
}
}
}
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -299,31 +298,29 @@ private fun AdvancedPrivacySettingsScreen(
)
}
if (RemoteConfig.internalUser) {
item {
Dividers.Default()
}
item {
Dividers.Default()
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
item {
val label = buildAnnotatedString {
append(stringResource(R.string.preferences_automatic_key_verification_body))
append(" ")
withLink(
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
callbacks.onAutomaticVerificationLearnMoreClick()
})
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
Rows.ToggleRow(
checked = state.allowAutomaticKeyVerification,
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
label = label,
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
)
}
}
}
@@ -70,6 +70,12 @@ object InAppPaymentsRepository {
private const val JOB_PREFIX = "InAppPayments__"
private val TAG = Log.tag(InAppPaymentsRepository::class.java)
/**
* Upper bound on how long we'll wait for the donations configuration before surfacing a retryable
* failure rather than leaving the user on an indefinite loading spinner (e.g. on a slow VPN).
*/
const val DONATIONS_CONFIGURATION_TIMEOUT_SECONDS = 30L
private val backupExpirationTimeout = 30.days
private val backupExpirationDeletion = 60.days
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.Currency
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Shared one-time payment methods that apply to both Stripe and PayPal payments.
@@ -77,6 +78,7 @@ object OneTimeInAppPaymentRepository {
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) }
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { config ->
config.getBoostAmounts().mapValues { (_, value) ->
@@ -97,6 +99,7 @@ object OneTimeInAppPaymentRepository {
.getDonationsConfiguration(Locale.getDefault())
}
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { it.getBoostBadges().first() }
}
@@ -107,8 +110,9 @@ object OneTimeInAppPaymentRepository {
*/
fun getMinimumDonationAmounts(): Single<Map<Currency, FiatMoney>> {
return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { it.getMinimumDonationAmounts() }
}
@@ -39,6 +39,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.math.BigDecimal
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -109,6 +110,7 @@ object RecurringInAppPaymentRepository {
return Single
.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
.subscribeOn(Schedulers.io())
.timeout(InAppPaymentsRepository.DONATIONS_CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.flatMap { it.flattenResult() }
.map { config ->
config.getSubscriptionLevels().map { (level, levelConfig) ->
@@ -18,6 +18,7 @@ import org.signal.core.util.money.PlatformCurrencyUtil
import org.signal.core.util.orNull
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
@@ -41,6 +42,7 @@ import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.ParseException
import java.util.Currency
import java.util.Optional
@@ -169,6 +171,8 @@ class DonateToSignalViewModel(
decimalFormat.parse(amount) as BigDecimal
} catch (e: NumberFormatException) {
BigDecimal.ZERO
} catch (e: ParseException) {
BigDecimal.ZERO
}
}
@@ -265,15 +269,6 @@ class DonateToSignalViewModel(
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(pendingOneTimeDonation = pendingOneTimeDonation.orNull())) }
}
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getBoostBadge().subscribeBy(
onSuccess = { badge ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
},
onError = {
Log.w(TAG, "Could not load boost badge", it)
}
)
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getMinimumDonationAmounts().subscribeBy(
onSuccess = { amountMap ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(minimumDonationAmounts = amountMap)) }
@@ -283,10 +278,14 @@ class DonateToSignalViewModel(
}
)
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeInAppPaymentRepository.getBoosts().toObservable()
val boostsAndBadge: Observable<Pair<Map<Currency, List<Boost>>, Badge>> = Single.zip(
oneTimeInAppPaymentRepository.getBoosts(),
oneTimeInAppPaymentRepository.getBoostBadge()
) { boosts, badge -> boosts to badge }.toObservable()
val oneTimeCurrency: Observable<Currency> = SignalStore.inAppPayments.observableOneTimeCurrency
oneTimeDonationDisposables += Observable.combineLatest(boosts, oneTimeCurrency) { boostMap, currency ->
oneTimeDonationDisposables += Observable.combineLatest(boostsAndBadge, oneTimeCurrency) { (boostMap, badge), currency ->
val boostList = if (currency in boostMap) {
boostMap[currency]!!
} else {
@@ -294,12 +293,13 @@ class DonateToSignalViewModel(
listOf()
}
Triple(boostList, currency, boostMap.keys)
OneTimeConfiguration(boostList, badge, currency, boostMap.keys)
}.subscribeBy(
onNext = { (boostList, currency, availableCurrencies) ->
onNext = { (boostList, badge, currency, availableCurrencies) ->
store.update { state ->
state.copy(
oneTimeDonationState = state.oneTimeDonationState.copy(
badge = badge,
boosts = boostList,
selectedBoost = null,
selectedCurrency = currency,
@@ -321,6 +321,13 @@ class DonateToSignalViewModel(
)
}
private data class OneTimeConfiguration(
val boosts: List<Boost>,
val badge: Badge,
val currency: Currency,
val availableCurrencies: Set<Currency>
)
private fun initializeMonthlyDonationState(subscriptionsRepository: RecurringInAppPaymentRepository) {
monitorLevelUpdateProcessing()
@@ -162,11 +162,18 @@ class ManageDonationsViewModel : ViewModel() {
private fun deriveRedemptionState(status: DonationRedemptionJobStatus, latestPayment: InAppPaymentTable.InAppPayment?): ManageDonationsState.RedemptionState {
return when (status) {
DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE
DonationRedemptionJobStatus.PendingKeepAlive -> ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH
DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED
DonationRedemptionJobStatus.PendingKeepAlive -> {
if (latestPayment.isPendingBankTransfer()) {
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER
} else {
ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH
}
}
is DonationRedemptionJobStatus.PendingExternalVerification -> {
if (latestPayment != null && (latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT || latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL)) {
if (latestPayment.isPendingBankTransfer()) {
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER
} else {
ManageDonationsState.RedemptionState.IN_PROGRESS
@@ -178,6 +185,10 @@ class ManageDonationsViewModel : ViewModel() {
}
}
private fun InAppPaymentTable.InAppPayment?.isPendingBankTransfer(): Boolean {
return this != null && (data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT || data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL)
}
private fun InAppPaymentTable.InAppPayment.toPendingOneTimeDonation(): PendingOneTimeDonation? {
if (type.recurring || data.amount == null || data.badge == null) {
return null
@@ -9,13 +9,17 @@ import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.navigation.fragment.NavHostFragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.compose.FragmentBackPressedInfo
import org.thoughtcrime.securesms.compose.FragmentBackPressedInfoProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class ConversationSettingsNavHostFragment : NavHostFragment() {
class ConversationSettingsNavHostFragment : NavHostFragment(), FragmentBackPressedInfoProvider {
companion object {
suspend fun createArgs(recipientId: RecipientId): Bundle {
@@ -36,4 +40,14 @@ class ConversationSettingsNavHostFragment : NavHostFragment() {
navController.setGraph(R.navigation.conversation_settings, args)
super.onCreate(savedInstanceState)
}
override fun getFragmentBackPressedInfo(): Flow<FragmentBackPressedInfo> {
return navController.currentBackStackEntryFlow.map {
if (navController.previousBackStackEntry != null) {
FragmentBackPressedInfo.Enabled { navController.popBackStack() }
} else {
FragmentBackPressedInfo.Disabled
}
}
}
}
@@ -20,7 +20,7 @@ object ConversationSettingsNavigator {
recipient: Recipient
) {
if (activity is MainNavigationChatDetailRouter) {
activity.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
activity.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id, isContentRoot = true))
return
}
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.C
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RxDatabaseObserver
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
@@ -79,10 +80,6 @@ sealed class ConversationSettingsViewModel(
} ?: emptyList()
}
store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state ->
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
}
store.update(sharedMedia) { mediaRecords, state ->
if (!cleared) {
state.copy(
@@ -101,6 +98,17 @@ sealed class ConversationSettingsViewModel(
sharedMediaUpdateTrigger.postValue(Unit)
}
fun observeConversationForCallUpdates(threadId: Long) {
disposable += RxDatabaseObserver.conversation(threadId)
.toObservable()
.switchMapSingle { repository.getCallEvents(callMessageIds) }
.subscribe { callRecords ->
store.update { state ->
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
}
}
}
fun onReportSpam(): Maybe<Unit> {
return if (store.state.threadId > 0 && store.state.recipient != Recipient.UNKNOWN) {
messageRequestRepository.reportSpamMessageRequest(store.state.recipient.id, store.state.threadId)
@@ -218,6 +226,7 @@ sealed class ConversationSettingsViewModel(
store.update { state ->
state.copy(threadId = threadId)
}
observeConversationForCallUpdates(threadId)
}
if (recipientId != Recipient.self().id) {
@@ -344,6 +353,7 @@ sealed class ConversationSettingsViewModel(
store.update { state ->
state.copy(threadId = threadId)
}
observeConversationForCallUpdates(threadId)
}
store.update(liveGroup.selfCanEditGroupAttributes()) { selfCanEditGroupAttributes, state ->
@@ -7,7 +7,12 @@ package org.thoughtcrime.securesms.components.settings.conversation
import androidx.annotation.WorkerThread
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
@@ -98,18 +103,18 @@ data class InternalConversationSettingsState(
val capabilities: RecipientRecord.Capabilities? = SignalDatabase.recipients.getCapabilities(recipient.id)
if (capabilities != null) {
AnnotatedString("No capabilities right now.")
// Left as an example in case we add one in the future
// val style: SpanStyle = when (capabilities.storageServiceEncryptionV2) {
// Recipient.Capability.SUPPORTED -> SpanStyle(color = Color(0, 150, 0))
// Recipient.Capability.NOT_SUPPORTED -> SpanStyle(color = Color.Red)
// Recipient.Capability.UNKNOWN -> SpanStyle(fontStyle = FontStyle.Italic)
// }
//
// buildAnnotatedString {
// withStyle(style = style) {
// append("SSREv2")
// }
// }
// Always leave one as an example in case we add one in the future
val style: SpanStyle = when (capabilities.usernameSyncMessages) {
Recipient.Capability.SUPPORTED -> SpanStyle(color = Color(0, 150, 0))
Recipient.Capability.NOT_SUPPORTED -> SpanStyle(color = Color.Red)
Recipient.Capability.UNKNOWN -> SpanStyle(fontStyle = FontStyle.Italic)
}
buildAnnotatedString {
withStyle(style = style) {
append("usernameSyncMessages")
}
}
} else {
AnnotatedString("Recipient not found!")
}
@@ -7,11 +7,13 @@ import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.visible
/**
* Renders a single call preference row when displaying call info.
@@ -41,6 +43,25 @@ object CallPreference {
binding.callIcon.setImageResource(getCallIcon(model.call))
binding.callType.text = getCallType(model.call)
binding.callTime.text = getCallTime(model.record)
presentTimer(model.record)
}
private fun presentTimer(messageRecord: MessageRecord) {
if (messageRecord.expiresIn > 0 && messageRecord.expireStarted > 0) {
binding.callTimer.visible = true
binding.callTimer.setPercentComplete(0f)
if (messageRecord.expireStarted > 0) {
binding.callTimer.setExpirationTime(messageRecord.expireStarted, messageRecord.expiresIn)
binding.callTimer.startAnimation()
if (messageRecord.expireStarted + messageRecord.expiresIn <= System.currentTimeMillis()) {
AppDependencies.expiringMessageManager.checkSchedule()
}
}
} else {
binding.callTimer.visible = false
}
}
@DrawableRes
@@ -105,7 +105,7 @@ object RecipientPreference {
} else {
if (recipient.isSystemContact) {
SpannableStringBuilder(recipient.getDisplayName(context)).apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
val drawable = context.requireDrawable(CoreUiR.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
@@ -5,55 +5,70 @@
package org.thoughtcrime.securesms.components.transfercontrols
import android.content.Context
import android.os.Build
import android.text.StaticLayout
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.updateLayoutParams
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ByteSize
import org.signal.core.util.ThrottledDebouncer
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.components.RecyclerViewParentTransitionController
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.databinding.TransferControlsViewBinding
import org.thoughtcrime.securesms.events.PartProgressEvent
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.UUID
import kotlin.math.ceil
import kotlin.math.roundToInt
class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {
private val uuid = UUID.randomUUID().toString()
private val binding: TransferControlsViewBinding
/**
* Displays the start/cancel/progress controls that overlay an attachment thumbnail.
*/
class TransferControlView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AbstractComposeView(context, attrs, defStyleAttr) {
companion object {
private val TAG = Log.tag(TransferControlView::class.java)
/** Flip to true locally to trace a single view's render transitions and ignored progress events. */
private const val VERBOSE_DEVELOPMENT_LOGGING = false
}
private var state = TransferControlViewState()
private val progressUpdateDebouncer: ThrottledDebouncer = ThrottledDebouncer(100)
private var mode: Mode = Mode.GONE
/** Throttled observable flow of [state] */
private var renderState by mutableStateOf<TransferControlsRenderState>(TransferControlsRenderState.Gone)
private val progressUpdateDebouncer = ThrottledDebouncer(100)
/** Per-instance id so a single recycled view can be isolated in logcat when [VERBOSE_DEVELOPMENT_LOGGING] is on. */
private val viewId by lazy { UUID.randomUUID().toString().take(8) }
init {
tag = uuid
binding = TransferControlsViewBinding.inflate(LayoutInflater.from(context), this)
visibility = GONE
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
isLongClickable = false
addOnAttachStateChangeListener(RecyclerViewParentTransitionController(child = this))
}
@Composable
override fun Content() {
SignalTheme {
TransferControls(
state = renderState,
onStartClick = { state.startTransferClickListener?.onClick(this) },
onCancelClick = { state.cancelTransferClickedListener?.onClick(this) },
onPlayClick = { state.instantPlaybackClickListener?.onClick(this) }
)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this)
@@ -64,499 +79,61 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
EventBus.getDefault().unregister(this)
}
private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) {
val newState = stateFactory.invoke(state)
val oldMode = deriveMode(state)
val newMode = deriveMode(newState)
if ((newState != state || oldMode != newMode) && !(oldMode == Mode.GONE && newMode == Mode.GONE)) {
progressUpdateDebouncer.publish {
applyState(newState)
}
}
state = newState
}
fun isGone(): Boolean {
return mode == Mode.GONE
return TransferControls.deriveRenderState(state) is TransferControlsRenderState.Gone
}
private fun applyState(currentState: TransferControlViewState) {
val mode = deriveMode(currentState)
verboseLog("New state applying, mode = $mode")
private fun updateState(stateFactory: (TransferControlViewState) -> TransferControlViewState) {
val newState = stateFactory(state)
children.forEach {
it.clearAnimation()
val oldRender = TransferControls.deriveRenderState(state)
val newRender = TransferControls.deriveRenderState(newState)
state = newState
if (oldRender == newRender) {
return
}
when (mode) {
Mode.PENDING_GALLERY -> displayPendingGallery(currentState)
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> displayPendingGalleryWithPlayable(currentState)
Mode.PENDING_SINGLE_ITEM -> displayPendingSingleItem(currentState)
Mode.PENDING_VIDEO_PLAYABLE -> displayPendingPlayableVideo(currentState)
Mode.DOWNLOADING_GALLERY -> displayDownloadingGallery(currentState)
Mode.DOWNLOADING_SINGLE_ITEM -> displayDownloadingSingleItem(currentState)
Mode.DOWNLOADING_VIDEO_PLAYABLE -> displayDownloadingPlayableVideo(currentState)
Mode.UPLOADING_GALLERY -> displayUploadingGallery(currentState)
Mode.UPLOADING_SINGLE_ITEM -> displayUploadingSingleItem(currentState)
Mode.RETRY_DOWNLOADING -> displayRetry(currentState, false)
Mode.RETRY_UPLOADING -> displayRetry(currentState, true)
Mode.GONE -> displayChildrenAsGone()
}
this.mode = mode
}
verboseLog { "render $oldRender -> $newRender slides=[${slidesAsLogString(newState.slides)}]" }
private fun deriveMode(currentState: TransferControlViewState): Mode {
if (currentState.slides.isEmpty()) {
verboseLog("Setting empty slide deck to GONE")
return Mode.GONE
}
if (currentState.slides.all { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }) {
verboseLog("Setting slide deck that's finished to GONE\n\t${slidesAsListOfTimestamps(currentState.slides)}")
return Mode.GONE
}
if (currentState.isVisible) {
if (currentState.slides.size == 1) {
val slide = currentState.slides.first()
if (slide.hasVideo()) {
if (currentState.isUpload) {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
Mode.UPLOADING_SINGLE_ITEM
}
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
Mode.PENDING_SINGLE_ITEM
}
else -> {
Mode.RETRY_UPLOADING
}
}
} else {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
if (currentState.playableWhileDownloading) {
Mode.DOWNLOADING_VIDEO_PLAYABLE
} else {
Mode.DOWNLOADING_SINGLE_ITEM
}
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
Mode.RETRY_DOWNLOADING
}
else -> {
if (currentState.playableWhileDownloading) {
Mode.PENDING_VIDEO_PLAYABLE
} else {
Mode.PENDING_SINGLE_ITEM
}
}
}
}
} else {
return if (currentState.isUpload) {
when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
Mode.RETRY_UPLOADING
}
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
Mode.PENDING_SINGLE_ITEM
}
else -> {
Mode.UPLOADING_SINGLE_ITEM
}
}
} else {
return when (slide.transferState) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
Mode.DOWNLOADING_SINGLE_ITEM
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
Mode.RETRY_DOWNLOADING
}
else -> {
Mode.PENDING_SINGLE_ITEM
}
}
}
}
} else {
when (getTransferState(currentState.slides)) {
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
return if (currentState.isUpload) {
Mode.UPLOADING_GALLERY
} else {
Mode.DOWNLOADING_GALLERY
}
}
AttachmentTable.TRANSFER_PROGRESS_PENDING -> {
return if (containsPlayableSlides(currentState.slides)) {
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE
} else {
Mode.PENDING_GALLERY
}
}
AttachmentTable.TRANSFER_PROGRESS_FAILED -> {
return if (currentState.isUpload) {
Mode.RETRY_UPLOADING
} else {
Mode.RETRY_DOWNLOADING
}
}
AttachmentTable.TRANSFER_PROGRESS_DONE -> {
verboseLog("[Case 2] Setting slide deck that's finished to GONE\t${slidesAsListOfTimestamps(currentState.slides)}")
return Mode.GONE
}
}
if (oldRender is TransferControlsRenderState.InProgress && oldRender.isProgressOnlyDifference(newRender)) {
progressUpdateDebouncer.publish {
renderState = newRender
visibility = VISIBLE
}
} else {
verboseLog("Setting slide deck to GONE because isVisible is false:\t${slidesAsListOfTimestamps(currentState.slides)}")
return Mode.GONE
}
Log.i(TAG, "[$uuid] Hit default mode case, this should not happen.")
return Mode.GONE
}
private fun displayPendingGallery(currentState: TransferControlViewState) {
binding.primaryProgressView.startClickListener = currentState.startTransferClickListener
applyFocusableAndClickable(
currentState,
listOf(binding.primaryProgressView, binding.primaryDetailsText, binding.primaryBackground),
listOf(binding.secondaryProgressView, binding.playVideoButton)
)
binding.primaryProgressView.setStopped(false)
showAllViews(
playVideoButton = false,
secondaryProgressView = false,
secondaryDetailsText = currentState.showSecondaryText
)
binding.primaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.primaryBackground.setOnClickListener(currentState.startTransferClickListener)
binding.primaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-PRIMARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(PRIMARY_TEXT_OFFSET_DP).toFloat()
}
setSecondaryDetailsText(currentState)
}
private fun displayPendingGalleryWithPlayable(currentState: TransferControlViewState) {
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
super.setClickable(false)
binding.secondaryProgressView.isClickable = currentState.showSecondaryText
binding.secondaryProgressView.isFocusable = currentState.showSecondaryText
binding.secondaryDetailsText.isClickable = currentState.showSecondaryText
binding.secondaryDetailsText.isFocusable = currentState.showSecondaryText
binding.secondaryBackground.isClickable = currentState.showSecondaryText
binding.secondaryBackground.isFocusable = currentState.showSecondaryText
binding.primaryProgressView.isClickable = false
binding.primaryProgressView.isFocusable = false
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryProgressView = currentState.showSecondaryText,
secondaryDetailsText = currentState.showSecondaryText
)
binding.secondaryProgressView.setStopped(false)
setSecondaryDetailsText(currentState)
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat()
}
}
private fun displayPendingSingleItem(currentState: TransferControlViewState) {
binding.primaryProgressView.startClickListener = currentState.startTransferClickListener
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton))
binding.primaryProgressView.setStopped(false)
showAllViews(
playVideoButton = false,
primaryDetailsText = false,
secondaryProgressView = false,
secondaryDetailsText = currentState.showSecondaryText
)
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayPendingPlayableVideo(currentState: TransferControlViewState) {
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener)
applyFocusableAndClickable(
currentState,
listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground, binding.playVideoButton),
listOf(binding.primaryProgressView)
)
binding.secondaryProgressView.setStopped(false)
showAllViews(
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText,
secondaryProgressView = currentState.showSecondaryText
)
setSecondaryDetailsText(currentState)
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-SECONDARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(SECONDARY_TEXT_OFFSET_DP).toFloat()
}
}
private fun displayDownloadingGallery(currentState: TransferControlViewState) {
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText
)
val progress = calculateProgress(currentState)
if (progress == 0f) {
binding.secondaryProgressView.setProgress(progress)
} else {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
binding.secondaryProgressView.setProgress(progress)
}
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayDownloadingSingleItem(currentState: TransferControlViewState) {
binding.primaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView), listOf(binding.secondaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryDetailsText = false,
secondaryProgressView = false,
secondaryDetailsText = currentState.showSecondaryText
)
val progress = calculateProgress(currentState)
if (progress == 0f) {
binding.primaryProgressView.setProgress(progress)
} else {
binding.primaryProgressView.setProgress(progress)
}
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayDownloadingPlayableVideo(currentState: TransferControlViewState) {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView, binding.playVideoButton), listOf(binding.primaryProgressView))
showAllViews(
primaryDetailsText = false,
secondaryProgressView = currentState.showSecondaryText,
secondaryDetailsText = currentState.showSecondaryText
)
binding.playVideoButton.setOnClickListener(currentState.instantPlaybackClickListener)
val progress = calculateProgress(currentState)
if (progress == 0f) {
binding.secondaryProgressView.setProgress(progress)
} else {
binding.secondaryProgressView.setProgress(progress)
}
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayUploadingSingleItem(currentState: TransferControlViewState) {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText
)
val progress = calculateProgress(currentState)
binding.secondaryProgressView.setProgress(progress)
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayUploadingGallery(currentState: TransferControlViewState) {
binding.secondaryProgressView.cancelClickListener = currentState.cancelTransferClickedListener
applyFocusableAndClickable(currentState, listOf(binding.secondaryProgressView), listOf(binding.primaryProgressView, binding.playVideoButton))
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false
)
val progress = calculateProgress(currentState)
binding.secondaryProgressView.setProgress(progress)
binding.secondaryDetailsText.translationX = 0f
setSecondaryDetailsText(currentState)
}
private fun displayRetry(currentState: TransferControlViewState, isUploading: Boolean) {
if (currentState.startTransferClickListener == null) {
Log.w(TAG, "No click listener set for retry!")
}
binding.secondaryProgressView.startClickListener = currentState.startTransferClickListener
applyFocusableAndClickable(
currentState,
listOf(binding.secondaryProgressView, binding.secondaryDetailsText, binding.secondaryBackground),
listOf(binding.primaryProgressView, binding.playVideoButton)
)
showAllViews(
playVideoButton = false,
primaryProgressView = false,
primaryDetailsText = false,
secondaryDetailsText = currentState.showSecondaryText
)
binding.secondaryBackground.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryDetailsText.setOnClickListener(currentState.startTransferClickListener)
binding.secondaryProgressView.setStopped(isUploading)
setSecondaryDetailsText(currentState)
binding.secondaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat()
} else {
ViewUtil.dpToPx(RETRY_SECONDARY_TEXT_OFFSET_DP).toFloat()
}
}
private fun displayChildrenAsGone() {
children.forEach {
if (it.visible && it.animation == null) {
ViewUtil.fadeOut(it, 250)
progressUpdateDebouncer.clear()
renderState = newRender
if (newRender !is TransferControlsRenderState.Gone) {
visibility = VISIBLE
}
}
}
/**
* Shows all views by defaults, but allows individual views to be overridden to not be shown.
*
* @param root
* @param playVideoButton
* @param primaryProgressView
* @param primaryDetailsText
* @param secondaryProgressView
* @param secondaryDetailsText
*/
private fun showAllViews(
root: Boolean = true,
playVideoButton: Boolean = true,
primaryProgressView: Boolean = true,
primaryDetailsText: Boolean = true,
secondaryProgressView: Boolean = true,
secondaryDetailsText: Boolean = true
) {
this.visible = root
binding.playVideoButton.visible = playVideoButton
binding.primaryProgressView.visibility = if (primaryProgressView) View.VISIBLE else View.INVISIBLE
binding.primaryDetailsText.visible = primaryDetailsText
binding.primaryBackground.visible = primaryProgressView || primaryDetailsText || playVideoButton
binding.secondaryProgressView.visible = secondaryProgressView
binding.secondaryDetailsText.visible = secondaryDetailsText
binding.secondaryBackground.visible = secondaryProgressView || secondaryDetailsText
val textPadding = if (secondaryProgressView) {
context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_progressbar_to_textview_margin)
} else {
context.resources.getDimensionPixelSize(R.dimen.transfer_control_view_parent_to_textview_margin)
}
ViewUtil.setPaddingStart(binding.secondaryDetailsText, textPadding)
if (ViewUtil.isLtr(binding.secondaryDetailsText)) {
(binding.secondaryDetailsText.layoutParams as MarginLayoutParams).leftMargin = textPadding
} else {
(binding.secondaryDetailsText.layoutParams as MarginLayoutParams).rightMargin = textPadding
}
}
private fun applyFocusableAndClickable(currentState: TransferControlViewState, activeViews: List<View>, inactiveViews: List<View>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusIntDef = if (currentState.isFocusable) View.FOCUSABLE else View.NOT_FOCUSABLE
activeViews.forEach { it.focusable = focusIntDef }
inactiveViews.forEach { it.focusable = View.NOT_FOCUSABLE }
}
activeViews.forEach { it.isClickable = currentState.isClickable }
inactiveViews.forEach {
it.setOnClickListener(null)
it.isClickable = false
}
}
override fun setFocusable(focusable: Boolean) {
super.setFocusable(false)
verboseLog("setFocusable update: $focusable")
updateState { it.copy(isFocusable = focusable) }
}
override fun setClickable(clickable: Boolean) {
super.setClickable(false)
verboseLog("setClickable update: $clickable")
updateState { it.copy(isClickable = clickable) }
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventAsync(event: PartProgressEvent) {
val attachment = event.attachment
updateState {
verboseLog("onEventAsync update")
if (!it.networkProgress.containsKey(attachment)) {
verboseLog("onEventAsync update ignored")
verboseLog { "Ignoring progress event for an attachment not in this view's slide set (likely a recycled view). ts=${attachment.uploadTimestamp}" }
return@updateState it
}
if (event.type == PartProgressEvent.Type.COMPRESSION) {
val mutableMap = it.compressionProgress.toMutableMap()
val updateEvent = Progress.fromEvent(event)
val existingEvent = mutableMap[attachment]
if (existingEvent == null || updateEvent.completed > existingEvent.completed) {
mutableMap[attachment] = updateEvent
} else if (updateEvent.completed < 0.bytes) {
mutableMap.remove(attachment)
}
verboseLog("onEventAsync compression update")
return@updateState it.copy(compressionProgress = mutableMap.toMap())
val progress = it.compressionProgress.toMutableMap()
progress.applyProgress(attachment, Progress.fromEvent(event))
return@updateState it.copy(compressionProgress = progress.toMap())
} else {
val mutableMap = it.networkProgress.toMutableMap()
val updateEvent = Progress.fromEvent(event)
val existingEvent = mutableMap[attachment]
if (existingEvent == null || updateEvent.completed > existingEvent.completed) {
mutableMap[attachment] = updateEvent
} else if (updateEvent.completed < 0.bytes) {
mutableMap.remove(attachment)
}
verboseLog("onEventAsync network update")
return@updateState it.copy(networkProgress = mutableMap.toMap())
val progress = it.networkProgress.toMutableMap()
progress.applyProgress(attachment, Progress.fromEvent(event))
return@updateState it.copy(networkProgress = progress.toMap())
}
}
}
fun setSlides(slides: List<Slide>) {
require(slides.isNotEmpty()) { "[$uuid] Must provide at least one slide." }
require(slides.isNotEmpty()) { "Must provide at least one slide." }
updateState { state ->
verboseLog("State update for new slides: ${slidesAsListOfTimestamps(slides)}")
val isNewSlideSet = !isUpdateToExistingSet(state, slides)
val networkProgress: MutableMap<Attachment, Progress> = if (isNewSlideSet) HashMap() else state.networkProgress.toMutableMap()
if (isNewSlideSet) {
@@ -577,25 +154,14 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
(it.asAttachment() as? DatabaseAttachment)?.hasData == true
}
val result = state.copy(
state.copy(
slides = slides,
networkProgress = networkProgress,
compressionProgress = compressionProgress,
playableWhileDownloading = playableWhileDownloading,
isUpload = isUpload
)
verboseLog("New state calculated and being returned for new slides: ${slidesAsListOfTimestamps(slides)}\n$result")
return@updateState result
}
verboseLog("End of setSlides() for ${slidesAsListOfTimestamps(slides)}")
}
private fun slidesAsListOfTimestamps(slides: List<Slide>): String {
if (!VERBOSE_DEVELOPMENT_LOGGING) {
return ""
}
return slides.map { it.asAttachment().uploadTimestamp }.joinToString()
}
private fun isUpdateToExistingSet(currentState: TransferControlViewState, slides: List<Slide>): Boolean {
@@ -611,171 +177,60 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
}
fun setTransferClickListener(listener: OnClickListener) {
verboseLog("transferClickListener update")
updateState {
it.copy(
startTransferClickListener = listener
)
}
updateState { it.copy(startTransferClickListener = listener) }
}
fun setCancelClickListener(listener: OnClickListener) {
verboseLog("cancelClickListener update")
updateState {
it.copy(
cancelTransferClickedListener = listener
)
}
updateState { it.copy(cancelTransferClickedListener = listener) }
}
fun setInstantPlaybackClickListener(listener: OnClickListener) {
verboseLog("instantPlaybackClickListener update")
updateState {
it.copy(
instantPlaybackClickListener = listener
)
}
updateState { it.copy(instantPlaybackClickListener = listener) }
}
fun clear() {
clearAnimation()
visibility = GONE
updateState { TransferControlViewState() }
}
fun setShowSecondaryText(showSecondaryText: Boolean) {
verboseLog("showSecondaryText update: $showSecondaryText")
updateState {
it.copy(
showSecondaryText = showSecondaryText
)
}
updateState { it.copy(showSecondaryText = showSecondaryText) }
}
fun setVisible(isVisible: Boolean) {
verboseLog("showSecondaryText update: $isVisible")
updateState {
it.copy(
isVisible = isVisible
)
updateState { it.copy(isVisible = isVisible) }
}
fun setAwaitingPrimaryResponse(awaiting: Boolean) {
updateState { it.copy(awaitingPrimaryResponse = awaiting) }
}
override fun setFocusable(focusable: Boolean) {
super.setFocusable(false)
updateState { it.copy(isFocusable = focusable) }
}
override fun setClickable(clickable: Boolean) {
super.setClickable(false)
updateState { it.copy(isClickable = clickable) }
}
private fun MutableMap<Attachment, Progress>.applyProgress(attachment: Attachment, update: Progress) {
if (update.completed < 0.bytes) {
remove(attachment)
} else {
put(attachment, update)
}
}
private fun isCompressing(state: TransferControlViewState): Boolean {
val total = state.compressionProgress.sumTotal()
return total > 0.bytes && state.compressionProgress.sumCompleted().percentageOf(total) < 0.99f
}
private fun calculateProgress(state: TransferControlViewState): Float {
val totalCompressionProgress: Float = state.compressionProgress.values.map { it.completed.percentageOf(it.total) }.sum()
val totalDownloadProgress: Float = state.networkProgress.values.map { it.completed.percentageOf(it.total) }.sum()
val weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress
val weightedTotal = (UPLOAD_TASK_WEIGHT * state.networkProgress.size + COMPRESSION_TASK_WEIGHT * state.compressionProgress.size).toFloat()
return weightedProgress / weightedTotal
}
private fun setSecondaryDetailsText(currentState: TransferControlViewState) {
when (deriveMode(currentState)) {
Mode.PENDING_GALLERY -> {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
val remainingSlides = currentState.slides.filterNot { it.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE }
val downloadCount = remainingSlides.size
binding.primaryDetailsText.text = context.resources.getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount)
val size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted()
binding.secondaryDetailsText.text = size.toUnitString()
}
Mode.PENDING_GALLERY_CONTAINS_PLAYABLE -> {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
val size = currentState.networkProgress.sumTotal() - currentState.networkProgress.sumCompleted()
binding.secondaryDetailsText.text = size.toUnitString()
}
Mode.PENDING_SINGLE_ITEM, Mode.PENDING_VIDEO_PLAYABLE -> {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
val size: ByteSize = (currentState.slides.sumOf { it.asAttachment().size }).bytes
binding.secondaryDetailsText.text = size.toUnitString()
}
Mode.DOWNLOADING_GALLERY, Mode.DOWNLOADING_SINGLE_ITEM, Mode.DOWNLOADING_VIDEO_PLAYABLE, Mode.UPLOADING_GALLERY, Mode.UPLOADING_SINGLE_ITEM -> {
if (currentState.isUpload && (currentState.networkProgress.sumCompleted() == 0.bytes || isCompressing(currentState))) {
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
binding.secondaryDetailsText.text = context.getString(R.string.TransferControlView__processing)
} else {
val progressMiB = currentState.networkProgress.sumCompleted().toUnitString()
val totalMiB = currentState.networkProgress.sumTotal().toUnitString()
val completedLabel = context.resources.getString(R.string.TransferControlView__download_progress_s_s, totalMiB, totalMiB)
val desiredWidth = StaticLayout.getDesiredWidth(completedLabel, binding.secondaryDetailsText.paint)
binding.secondaryDetailsText.text = context.resources.getString(R.string.TransferControlView__download_progress_s_s, progressMiB, totalMiB)
val roundedWidth = ceil(desiredWidth.toDouble()).roundToInt() + binding.secondaryDetailsText.compoundPaddingLeft + binding.secondaryDetailsText.compoundPaddingRight
binding.secondaryDetailsText.updateLayoutParams {
width = roundedWidth
}
}
}
Mode.RETRY_DOWNLOADING, Mode.RETRY_UPLOADING -> {
binding.secondaryDetailsText.text = resources.getString(R.string.NetworkFailure__retry)
binding.secondaryDetailsText.updateLayoutParams {
width = ViewGroup.LayoutParams.WRAP_CONTENT
}
}
Mode.GONE -> Unit
}
}
/**
* This is an extremely chatty logging mode for local development. Each view is assigned a UUID so that you can filter by view inside a conversation.
*/
private fun verboseLog(message: String) {
private inline fun verboseLog(message: () -> String) {
if (VERBOSE_DEVELOPMENT_LOGGING) {
Log.d(TAG, "[$uuid] $message")
Log.d(TAG, "[$viewId] ${message()}")
}
}
companion object {
private const val TAG = "TransferControlView"
private const val VERBOSE_DEVELOPMENT_LOGGING = false
private const val UPLOAD_TASK_WEIGHT = 1
private const val SECONDARY_TEXT_OFFSET_DP = 6
private const val RETRY_SECONDARY_TEXT_OFFSET_DP = 6
private const val PRIMARY_TEXT_OFFSET_DP = 4
/**
* A weighting compared to [UPLOAD_TASK_WEIGHT]
*/
private const val COMPRESSION_TASK_WEIGHT = 3
@JvmStatic
fun getTransferState(slides: List<Slide>): Int {
var transferState = AttachmentTable.TRANSFER_PROGRESS_DONE
var allFailed = true
for (slide in slides) {
if (slide.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
allFailed = false
transferState = if (slide.transferState == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
slide.transferState
} else {
transferState.coerceAtLeast(slide.transferState)
}
}
}
return if (allFailed) AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE else transferState
}
@JvmStatic
fun containsPlayableSlides(slides: List<Slide>): Boolean {
return slides.any { MediaUtil.isInstantVideoSupported(it) }
}
private fun slidesAsLogString(slides: List<Slide>): String {
return slides.joinToString { "ts=${it.asAttachment().uploadTimestamp},xfer=${it.transferState}" }
}
data class Progress(val completed: ByteSize, val total: ByteSize) {
@@ -785,27 +240,4 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
}
}
}
private fun Map<Attachment, Progress>.sumCompleted(): ByteSize {
return this.values.sumOf { it.completed.inWholeBytes }.bytes
}
private fun Map<Attachment, Progress>.sumTotal(): ByteSize {
return this.values.sumOf { it.total.inWholeBytes }.bytes
}
enum class Mode {
PENDING_GALLERY,
PENDING_GALLERY_CONTAINS_PLAYABLE,
PENDING_SINGLE_ITEM,
PENDING_VIDEO_PLAYABLE,
DOWNLOADING_GALLERY,
DOWNLOADING_SINGLE_ITEM,
DOWNLOADING_VIDEO_PLAYABLE,
UPLOADING_GALLERY,
UPLOADING_SINGLE_ITEM,
RETRY_DOWNLOADING,
RETRY_UPLOADING,
GONE
}
}

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