Compare commits

..

360 Commits

Author SHA1 Message Date
Greyson Parrelli 8c9eb880cf Bump version to 5.23.0 2021-09-02 21:36:18 -04:00
Greyson Parrelli d7ddd85a90 Updated language translations. 2021-09-02 21:35:27 -04:00
Alex Hart 7d994b2ae1 Set proper money separator when presenting custom amount string to user in MoneyView. 2021-09-02 21:24:54 -04:00
Alex Hart 664d6475d9 Refresh media selection and sending flow with a shiny new UX. 2021-09-02 21:24:54 -04:00
Greyson Parrelli a940487611 Improve logging around rate-limiting. 2021-09-02 21:24:54 -04:00
Sgn-32 9f995d61f4 Fix padding for Payments icon and title. 2021-09-02 21:24:54 -04:00
Leonid Zavodnik a6690e1bde Update exoplayer version to v2.15
Fixes #11547
2021-09-02 21:24:54 -04:00
Greyson Parrelli d507df2e7e Increase max log size to 15mb. 2021-09-02 21:24:54 -04:00
Greyson Parrelli fa26eb2017 Switch back to mainline SQLCipher with true WAL mode. 2021-09-02 21:24:54 -04:00
Greyson Parrelli 0b53ba8950 Improve MMS database insertion performance. 2021-09-02 21:24:54 -04:00
Greyson Parrelli 7447e2497b Default the retry receipt flag to true. 2021-09-02 21:24:54 -04:00
Greyson Parrelli 7ac83625d3 Add a write-through cache to the identity store. 2021-09-02 21:24:53 -04:00
Cody Henthorne 50dfe7bc25 Update Staging KBS values. 2021-09-02 21:24:53 -04:00
Cody Henthorne 8e32592218 Clarify networking call order during registration flow. 2021-09-02 21:24:53 -04:00
Lucio Maciel a3d72fc06c Update message details UI. 2021-09-02 21:24:53 -04:00
Greyson Parrelli f5a6d61362 Add support for granular conversation data changes. 2021-09-02 21:24:53 -04:00
Greyson Parrelli bca2205945 Add measurements, improve MSL insert. 2021-09-02 21:24:53 -04:00
Alex Hart 1241f4c0e9 Enable MobileCoin in Germany, France, and Switzerland. 2021-09-02 21:24:53 -04:00
Graham Campbell f6253ad0bb Corrected Google trademark notice 2021-09-02 21:24:53 -04:00
Lucio Maciel 083301185c Update verify identity UI. 2021-09-02 21:24:53 -04:00
Lucio Maciel 0273d0f285 Save receipt timestamps on sms/mms database. 2021-09-02 21:24:53 -04:00
Cody Henthorne 3dc1ce3353 Bump version to 5.22.7 2021-09-02 16:44:02 -04:00
Cody Henthorne f8e077b824 Updated language translations. 2021-09-02 16:43:30 -04:00
Greyson Parrelli aec2ca1d87 Update libsignal-client to 0.9.0 2021-09-02 11:21:15 -04:00
Cody Henthorne 6e7a18ea11 Bump version to 5.22.6 2021-09-01 12:55:04 -04:00
Cody Henthorne fe54ec9d6c Updated language translations. 2021-09-01 12:49:23 -04:00
Greyson Parrelli 1819af3000 Fix possible crash when a contact merge results in no UUID.
After merging contacts, it's possible that we don't have a valid
UUID. We need to be careful not to use it.

Kind of a bummer, but the storage sync flow is currently the only flow
where we have this 'possibly not valid UUID'. In the future we should
probably use something else instead of a SignalServiceAddress to keep
that abstraction clean.
2021-09-01 10:46:42 -04:00
Cody Henthorne 3c177c4883 Bump version to 5.22.5 2021-08-31 10:18:33 -04:00
Cody Henthorne 2c871a36d0 Updated language translations. 2021-08-31 10:18:10 -04:00
Greyson Parrelli 6bde55f73b Only check remote registrationIds for active records. 2021-08-31 09:46:37 -04:00
Cody Henthorne 50b4e137b4 Bump version to 5.22.4 2021-08-30 20:43:11 -04:00
Cody Henthorne 4f6d39859c Updated language translations. 2021-08-30 20:38:20 -04:00
Greyson Parrelli 45a6894da1 Handle invalid registrationIds during sender key sends. 2021-08-30 20:32:41 -04:00
Alex Hart f71accea06 Revert "Replace use of AlertDialog.Builder with MaterialAlertDialogBuilder."
This reverts commit 9232eb7c16.
2021-08-30 20:32:41 -04:00
Greyson Parrelli 32888fa00b Re-enabled converation list observation while a conversation is open.
It honestly doesn't feel great to not have this, because when you back
out to the conversation list you have to wait for it to update.

Right now this seems like the lesser of two evils.
2021-08-30 20:32:41 -04:00
Greyson Parrelli eba3c55ec8 Fix issue where you couldn't delete a blocked announcement group. 2021-08-30 11:50:07 -04:00
Greyson Parrelli 21b82e291b Fix crash when building local e164-only contact record.
Fixes #11572
2021-08-30 10:03:18 -04:00
Alex Hart 8d9d84c4cc Add drawable padding to contact item. 2021-08-30 09:34:18 -03:00
Alex Hart 4c25264fbf Fix issue with conversation list invalidation. 2021-08-30 09:21:26 -03:00
Alex Hart 7410d664dd Bump version to 5.22.3 2021-08-27 14:43:38 -03:00
Alex Hart c878ba3cdf Updated language translations. 2021-08-27 14:43:38 -03:00
Greyson Parrelli 97798a146f Fix issue where request banner overlapped admin-only banner. 2021-08-27 14:43:38 -03:00
Greyson Parrelli 7c134a6c9d Fix issue where group leave failed to send in announcement group. 2021-08-27 14:43:38 -03:00
Greyson Parrelli 08008629b3 Fix some issues around SignalServiceAddress creation. 2021-08-27 14:43:38 -03:00
Greyson Parrelli a57adcb2b0 Remove identity store cache. 2021-08-27 14:43:38 -03:00
Alex Hart 7790cac0ee Invalidate conversation list when it is not newly started. 2021-08-27 14:43:38 -03:00
Alex Hart 349ad06c45 Fix crash when animation ends after onDestroyView. 2021-08-27 14:43:38 -03:00
Alex Hart 3a75d30732 Remove requireContext call from async runnable. 2021-08-27 09:10:54 -03:00
Alex Hart b48d4f3ec2 Bump version to 5.22.2 2021-08-26 17:41:09 -03:00
Alex Hart c92f36f9a8 Updated language translations. 2021-08-26 17:39:57 -03:00
Greyson Parrelli faa36d417c Switch back to mainline SQLCipher. 2021-08-26 16:05:52 -04:00
Alex Hart a2b6e003b6 Potential fix for bad contacts. 2021-08-26 16:42:40 -03:00
AsamK 406af58394 Use EmojiTextView to display group names in AvatarPreviewActivity. 2021-08-26 15:38:42 -03:00
Greyson Parrelli bd72fc8464 fixup! Revert some database transaction changes. 2021-08-26 12:06:28 -04:00
Greyson Parrelli 05fb1a52d2 Revert some database transaction changes. 2021-08-26 11:59:45 -04:00
Greyson Parrelli b21abb8e7e Fix crash during block list parsing. 2021-08-26 09:51:28 -04:00
Alex Hart b41e602539 Add hasGroupsInCommon to Recipient content check. 2021-08-26 10:46:06 -03:00
Alex Hart 3f233ed39f Use AttachmentsV2 if the resumable upload link from V3 becomes corrupted. 2021-08-26 10:24:20 -03:00
Alex Hart ade6f60e76 Skip attachment template if digest is null. 2021-08-26 10:14:12 -03:00
Greyson Parrelli 62d85e6878 Stop listening to database changes in conversation list when not visible. 2021-08-25 19:47:48 -04:00
Greyson Parrelli 4d985255a8 Fix deviceId log for retry receipts. 2021-08-25 19:33:50 -04:00
Alex Hart fd3ef0f557 Bump version to 5.22.1 2021-08-25 17:20:48 -03:00
Alex Hart 7f30300cd4 Updated language translations. 2021-08-25 17:20:48 -03:00
Greyson Parrelli 0459d118a3 Enable sender key by default. 2021-08-25 17:20:48 -03:00
Lucio Maciel c92f3b5dfd Fix theming on invite friends Activity. 2021-08-25 16:05:20 -03:00
Greyson Parrelli ba4d1c9844 Add a failsafe to prevent non-admin sends in announcement groups. 2021-08-25 14:20:49 -04:00
Greyson Parrelli 8c3a0c1f9f Fix crash after a backup restore. 2021-08-25 13:56:22 -04:00
Greyson Parrelli 1dc2a35d83 Fix overlapping text for not-in-group and announcement-only. 2021-08-25 13:52:19 -04:00
Greyson Parrelli 0a67731830 Add a write-through cache to the identity store. 2021-08-25 13:39:59 -04:00
Greyson Parrelli 28d86886bd Update handling of invalid unknown fields. 2021-08-25 13:34:29 -04:00
Greyson Parrelli b1fcea673a Allowing joining group calls in announcement groups. 2021-08-25 13:21:11 -04:00
Greyson Parrelli eb5418787a Disable the reply action in announcement groups. 2021-08-25 13:19:52 -04:00
Cody Henthorne adbda02aa4 Fix minor Group Call Ringing UI bugs. 2021-08-25 13:13:25 -04:00
Greyson Parrelli 307f47fa33 Prevent forwarding to announcement groups in new forward fragment. 2021-08-25 12:38:14 -04:00
Cody Henthorne c1fb4f9421 Include urgency in opaque call message sends. 2021-08-25 09:28:16 -04:00
Ehren Kret 6179c087fb Update URL for reaching Signal chat server. 2021-08-24 17:41:09 -04:00
Alex Hart ae2ba5d185 Bump version to 5.22.0 2021-08-24 16:59:09 -03:00
Alex Hart 91128be8f6 Updated language translations. 2021-08-24 16:59:09 -03:00
Greyson Parrelli 8748056130 Inline the announcement groups flag. 2021-08-24 16:59:09 -03:00
Greyson Parrelli 3c4e3cf048 Improve retrieval from the identity table. 2021-08-24 16:59:09 -03:00
Greyson Parrelli eb48ab1784 Disallow marking users as registered without a UUID. 2021-08-24 16:59:09 -03:00
Greyson Parrelli 665d9e31f6 Separate thread updates into a job and other perf improvements. 2021-08-24 16:59:09 -03:00
Cody Henthorne db7272730e Add Small Group Ringing support. 2021-08-24 16:59:09 -03:00
Greyson Parrelli 5787a5f68a Improve conversion of Recipient to SignalServiceAddress. 2021-08-24 16:59:09 -03:00
Alex Hart 1a21cafe6c Remove multi-forward feature flag. 2021-08-24 16:59:09 -03:00
Greyson Parrelli 7465818f44 Fix crash where we required a UUID from an unregistered user. 2021-08-24 16:59:09 -03:00
Lucio Maciel 62cb29fdb7 Update Invite friends screen UI. 2021-08-24 16:59:09 -03:00
Greyson Parrelli a85b08d9da Added an internal setting for disabling shake to report. 2021-08-24 16:59:09 -03:00
Greyson Parrelli b18c3ec1a9 Update filtered executor in LiveRecipientCache. 2021-08-24 16:59:09 -03:00
Greyson Parrelli 29489a664e Fix issue where synced media messages weren't downloading.
There was race where the AttachmentDownloadJob was run during a
transaction, which meant that it might not be able to see the message
that was just inserted.

Gotta be more careful now with WAL mode.
2021-08-24 16:59:09 -03:00
Greyson Parrelli dbb1e50d00 Migrate the identity table to be keyed off of libsignal IDs. 2021-08-24 16:59:09 -03:00
Greyson Parrelli 2068fa8041 Several sender key performance improvements.
- Remove extra unnecessary sync message
- Add a bulk session retrieval method
- Do the encrypt in a transaction
2021-08-24 16:59:09 -03:00
Cody Henthorne 194975d068 Fix lobby copy when another of your devices is solely already in the group call. 2021-08-24 09:09:27 -03:00
Greyson Parrelli b7a067e954 Use a more accurate starting point for message send timings. 2021-08-24 09:09:27 -03:00
Greyson Parrelli 1e050915ef Clean up unmigrated groups after recipient merge. 2021-08-24 09:09:27 -03:00
Alex Hart 6a5c234408 Always recalculate shown items when we update menu state in multiselect. 2021-08-24 09:09:27 -03:00
Alex Hart 7a1122b3f7 Force ConversationItem to intercept all touch events when in multiselect mode. 2021-08-24 09:09:27 -03:00
Sgn-32 962d943a22 Pretty print phone numbers of blocked users in privacy settings. 2021-08-24 09:09:27 -03:00
Goldmaster dbcc5d696d Update README.md copyright year and links.
add link to apk download from signals website in the readme

updated the copyright to the current year.
2021-08-24 09:09:27 -03:00
Sgn-32 9232eb7c16 Replace use of AlertDialog.Builder with MaterialAlertDialogBuilder. 2021-08-24 09:09:27 -03:00
Greyson Parrelli fc9b8f43dd Fix GV2 storage sync crash.
Fixes #11459
2021-08-24 09:09:27 -03:00
Lucio Maciel 5e8d74bc11 Fix lock screen issues. 2021-08-24 09:09:27 -03:00
Greyson Parrelli 642d1984c4 Ensure all SignalServiceAddresses have UUIDs. 2021-08-24 09:09:27 -03:00
Greyson Parrelli 0ab2100fa5 Update libsignal-client to 0.8.4 2021-08-24 09:09:27 -03:00
Greyson Parrelli 6618d696e4 Migrate the session table to be keyed off of libsignal IDs. 2021-08-24 09:09:27 -03:00
Greyson Parrelli c24dfdce34 Use a more readable method of listing selectable variants. 2021-08-24 09:09:27 -03:00
Greyson Parrelli 214e994e90 Update to SQLCipher with true WAL support. 2021-08-24 09:09:27 -03:00
Greyson Parrelli b904de5b50 Remove unused gradle code. 2021-08-24 09:07:54 -03:00
Ehren Kret ad7c81ef4e Limit JCenter dependencies 2021-08-24 09:07:54 -03:00
Alex Hart 3e8b5cdb61 Bump version to 5.21.6 2021-08-23 15:49:52 -03:00
Alex Hart 6aea849a42 Updated language translations. 2021-08-23 15:49:01 -03:00
Cody Henthorne cd0bf470a9 Fix applying default timer to first media message. 2021-08-23 10:18:42 -04:00
Greyson Parrelli c615b14c51 Bump version to 5.21.5 2021-08-19 21:19:56 -04:00
Greyson Parrelli 28bf6d300e Updated language translations. 2021-08-19 21:19:56 -04:00
Greyson Parrelli a1095f966c Do the account restore within a transaction. 2021-08-19 21:19:56 -04:00
Alex Hart 58a8902d4e Only finish action mode after forwards are sent. 2021-08-19 21:14:10 -04:00
Alex Hart e582976293 Fix issue with bad multiselect inset. 2021-08-19 15:34:14 -03:00
Alex Hart 143110047d Change counter to consider only unique conversation messages in multiselect. 2021-08-19 15:22:21 -03:00
Alex Hart c1324c7496 Fix check indicator covering update in multiselect. 2021-08-19 15:17:41 -03:00
Lucio Maciel 53eee2bd16 Fix timestamps with image+text. 2021-08-18 16:10:52 -03:00
Greyson Parrelli 86b1d104d9 Bump version to 5.21.4 2021-08-18 10:48:13 -04:00
Greyson Parrelli d1d2376210 Updated language translations. 2021-08-18 10:48:13 -04:00
Alex Hart 7bede7e98a Fix issue where forwarded messages would show unlock icon. 2021-08-18 10:48:13 -04:00
Lucio Maciel fec4a7692d Collapse timestamps on "deleted" messages. 2021-08-18 10:48:09 -04:00
Greyson Parrelli b58cede072 Fix issue with date header ID generation.
We render based on the date received, but were generating the ID with
the date sent. This caused the potential for a weird caching bug that
could cause us to render the wrong date.
2021-08-18 10:01:33 -04:00
Alex Hart 199fb517b1 Fix dark theme coloring for forward bottom sheet. 2021-08-18 09:33:29 -03:00
Alex Hart 921addf4c8 Fix error with vertical translation of quote cutout projection. 2021-08-18 09:33:29 -03:00
Greyson Parrelli 61aa991d79 Increase toast duration for forward error messages. 2021-08-18 08:32:21 -04:00
Alex Hart c1c95e1ae2 Disable predictive animation support on conversation layout manager. 2021-08-18 09:02:29 -03:00
Greyson Parrelli f95a29b0d4 Bump version to 5.21.3 2021-08-17 20:15:01 -04:00
Greyson Parrelli f7bb9c85af Updated language translations. 2021-08-17 20:14:35 -04:00
Greyson Parrelli ae30e4070c Default retry respond max age to 14 days. 2021-08-17 20:14:35 -04:00
Lucio Maciel 9a67c60b4e Don't inline jumbomoji timestamps. 2021-08-17 19:04:59 -04:00
Cody Henthorne e86b26bd11 Give call button text a bit more room and fix centering issue. 2021-08-17 16:46:05 -04:00
Lucio Maciel e7c259b1e9 Adjust timestamp alignment. 2021-08-17 17:22:23 -03:00
Alex Hart c65761a034 Fix several issues with multiforwarding.
* Better forwarding and animations.
* Handle audio with text.
* Increase max forwardable count to 32
* Onboarding dialog.
* Send forth link previews.
* Safety number support.
* Fix slide behaviour.
2021-08-17 16:15:09 -03:00
Alex Hart 0b37b0ee16 Fix crash with detached fragment. 2021-08-17 15:17:23 -03:00
Cody Henthorne d76e58ce09 Fix crash when updating empty thread on failed send. 2021-08-17 10:58:57 -04:00
Lucio Maciel 2b366f8c9c Fix audio with text footer. 2021-08-17 11:09:22 -03:00
Greyson Parrelli d43f7d6ad9 Bump version to 5.21.2 2021-08-16 21:22:09 -04:00
Greyson Parrelli 5b7932281e Updated language translations. 2021-08-16 21:18:15 -04:00
Lucio Maciel 0599f76ed5 Fix alignment issues for single line timestamps. 2021-08-16 20:50:33 -04:00
Niel Thiart 31e0f3edfb Fix Signal Direct Share Shortcuts not appearing in Android Sharesheet.
Fixes #11537
2021-08-16 20:50:33 -04:00
Alex Hart 17b568e6d1 Fix sticker forwarding. 2021-08-16 20:50:33 -04:00
Alex Hart 7c11962cb3 Fix custom notification vibration state. 2021-08-16 20:50:33 -04:00
Alex Hart a7c4199192 Add proper pluralization to message send toast. 2021-08-16 12:00:19 -03:00
Alex Hart 8cb3909093 Disable multiforward send button after the user presses it. 2021-08-16 11:50:53 -03:00
Alex Hart 7480ea66ec Fix issue where a document with text would cause a crash and not be multiselectable. 2021-08-16 11:36:03 -03:00
Cody Henthorne 8e94ced7b6 Bump version to 5.21.1 2021-08-13 17:50:08 -04:00
Cody Henthorne ffd86a96da Updated language translations. 2021-08-13 17:47:25 -04:00
Lucio Maciel d4cabce876 Fix crash when getLayout() is null. 2021-08-13 18:39:06 -03:00
Cody Henthorne a5790edb2b Bump version to 5.21.0 2021-08-13 14:17:32 -04:00
Cody Henthorne d247e2eabe Updated language translations. 2021-08-13 14:09:42 -04:00
Cody Henthorne f4d6de466b Fix long SMS send with no service failure loop. 2021-08-13 13:58:38 -04:00
Cody Henthorne 0838c0be27 Fix crash when sending media message as first message in conversation. 2021-08-13 13:58:38 -04:00
Alex Hart 7448183ff4 Update multi-forward work with tweaks from design. 2021-08-13 13:58:38 -04:00
Lucio Maciel 8e2a265cf3 Update emoji search index on system locale changes. 2021-08-13 13:58:38 -04:00
Cody Henthorne 8802cebb64 Prevent constantly requesting new video resolutions in group calls. 2021-08-13 13:58:38 -04:00
Lucio Maciel 0c6fe8bea3 Fix crash when encountering SMS calculate length security exception. 2021-08-13 13:58:38 -04:00
Alex Hart 49334ffd42 Implement proper mentions support for multiforward. 2021-08-13 13:58:38 -04:00
Lucio Maciel 4702ab1aeb Implement better detection of text only messages. 2021-08-13 13:58:38 -04:00
Lucio Maciel fe8fcb1394 Implement single line timestamps on conversation items. 2021-08-13 13:58:38 -04:00
Alex Hart dc1e56de4e Implement new bottom fragment UX for multiforward. 2021-08-13 13:58:38 -04:00
Jim Gustafson 561cca5208 Update RingRTC to v2.10.8 2021-08-13 13:58:38 -04:00
Alex Hart a291732c1a Check if already connected before connecting. 2021-08-13 13:58:38 -04:00
Alex Hart 28abc1e4ff Implement new Multiselect UX and groundwork for Multiforward. 2021-08-13 13:58:38 -04:00
Cody Henthorne 655e43a079 Update call UI to new designs. 2021-08-10 16:27:52 -04:00
Cody Henthorne 94b9a458e7 Bump version to 5.20.4 2021-08-10 16:27:31 -04:00
Cody Henthorne bb75730315 Updated language translations. 2021-08-10 16:21:59 -04:00
Alex Hart 824a8ac5f2 Fix RuntimeException during call initialization. 2021-08-10 16:08:55 -04:00
Cody Henthorne 3baf10f0e9 Fix bug with not showing entire long message when it contains no mentions. 2021-08-10 12:02:35 -04:00
Cody Henthorne cfab195e90 Bump version to 5.20.3 2021-08-09 14:40:04 -04:00
Cody Henthorne 503d7c77a0 Updated language translations. 2021-08-09 14:36:45 -04:00
Cody Henthorne f99ff32947 Fix NPE when operating on multiple conversations in batch mode. 2021-08-09 11:54:40 -04:00
Cody Henthorne 182c758d35 Revert "Fix NPE when operating on multiple conversations in batch mode."
This reverts commit fc51c4940c.
2021-08-09 11:52:38 -04:00
Greyson Parrelli f6b2d3faf8 Small refactor to building the sender key target list. 2021-08-06 17:49:08 -04:00
Greyson Parrelli 61c7959ffc Ensure typing indicators are sent as online messages with sender key. 2021-08-06 17:29:25 -04:00
Greyson Parrelli 67ccd14af2 Ensure certain sender key payloads are serialized properly. 2021-08-06 17:29:02 -04:00
Cody Henthorne 3c41b7322f Bump version to 5.20.2 2021-08-06 16:43:18 -04:00
Cody Henthorne d2118d0b53 Updated language translations. 2021-08-06 16:40:45 -04:00
Alex Hart 89af85c893 Fix MP4 Gif crash with ConversationUpdateItem
Due to adapter positions changing due to deletes and other such
nonsense, there are cases where update items are all of a sudden in our
playing or notplaying arrays. Checking for playable content before
trying to update positioning information seems to have fixed the issue.
2021-08-06 16:41:44 -03:00
Greyson Parrelli 0762a93787 Refactor protobuf validation exceptions. 2021-08-06 14:47:43 -04:00
Cody Henthorne 570b4d7150 Fix bug with processing and displaying long messages with mentions. 2021-08-06 13:19:44 -04:00
Cody Henthorne fc51c4940c Fix NPE when operating on multiple conversations in batch mode. 2021-08-06 11:14:33 -04:00
Alex Hart b9ffbb8e92 Fix issue where custom notifications were never enabled.
Older API levels do not have notification channel support, and
we were not checking this state to see if we should enable
the controls. Fix is to add a new controlsEnabled flag on the
state object and set it whenever we finish loading or when recp
changes.
2021-08-06 11:40:09 -03:00
Alex Hart de2c7d38bf Fix NPE in proximity sensor management.
If a device either does not have a proximity sensor or has a
non-functioning sensor, we can hit an NPE as soon as we hit
MainActivity. This fix ensures proper handling if a sensor is
unavailable.
2021-08-06 11:32:03 -03:00
Alex Hart c9597ef8dc Fix several small bugs with foldable calling.
* Set proper aspect ratio of pip in landscape mode.
* Fix some fade and adjustment from new UI states.
2021-08-06 11:27:27 -03:00
Greyson Parrelli a9bbee3880 Trim logs submitted via help and shake2report. 2021-08-05 18:23:20 -04:00
Greyson Parrelli 2bac1a7707 Fix race condition that could show an empty link preview after send. 2021-08-05 17:45:14 -04:00
Cody Henthorne 80e1b2c843 Bump version to 5.20.1 2021-08-05 16:08:17 -04:00
Cody Henthorne 7c2da69676 Updated language translations. 2021-08-05 16:02:58 -04:00
Alex Hart f25e8d602b Rewrite ContactSelectionListItem to utilize ConstraintLayout. 2021-08-05 16:42:32 -03:00
Greyson Parrelli b9c6c6b0f4 Include additional logging to assist in debugging. 2021-08-05 16:42:32 -03:00
Alex Hart 164f39e376 Fix issue where group count flashes in contact selection item. 2021-08-05 16:42:32 -03:00
Greyson Parrelli 49190125ef Locally track conversation open time. 2021-08-05 16:42:32 -03:00
Fumiaki Yoshimatsu 555e65d3ee Try a little harder to find a place to store the file before accepting a directory path that may not exist.
Fixes #11505
2021-08-05 16:42:32 -03:00
Greyson Parrelli 89b1243885 Add the "My Daily Life" sticker pack by Plastic Thing. 2021-08-05 16:42:32 -03:00
Cody Henthorne 3fca46de92 Alleviate database contention when archiving threads. 2021-08-05 16:42:32 -03:00
Alex Hart aa0d7c218f Stage secure outgoing message instead of unwrapped. 2021-08-05 16:42:32 -03:00
Greyson Parrelli 2b5b664a8f Update sender key flag. 2021-08-05 16:42:32 -03:00
Greyson Parrelli 784c373a0e Locally track message send time. 2021-08-05 16:42:32 -03:00
Alex Hart 37ae740138 Hide reveal dot if sender and recvr are both self. 2021-08-05 16:42:32 -03:00
Alex Hart fe9b8a9f47 Replace with new custom notifications page. 2021-08-05 16:42:32 -03:00
Alex Hart 3585667fb9 Bump version to 5.20.0 2021-08-04 13:39:31 -03:00
Alex Hart b4ed088529 Updated language translations. 2021-08-04 13:38:59 -03:00
Greyson Parrelli bdcf390e6e Verify a member is still in the group before using sender key. 2021-08-04 10:55:44 -04:00
Greyson Parrelli c7551881b8 Update handling of unrestricted UD access. 2021-08-04 10:36:49 -04:00
Greyson Parrelli c131754874 Add a system for locally tracking performance on-device. 2021-08-04 10:01:14 -04:00
Alex Hart c6c4988583 Apply proper rules for foldable aspect scaling in landscape and tabletop modes. 2021-08-04 10:57:21 -03:00
Alex Hart d43f044eb4 Add logic to only dismiss header views when in tabletop mode. 2021-08-04 10:53:53 -03:00
Fumiaki Yoshimatsu 9c71994804 Align text to "start" to show LTR text in RTL view correctly.
Fixes #11335
2021-08-04 10:29:03 -03:00
Sgn-32 654d98b0fe Use getSimpleRelativeTimeSpanString in getCallDateString 2021-08-04 10:01:15 -03:00
Fumiaki Yoshimatsu c06056d847 Force LTR layout in this view because the "playback" button should not be mirrored in RTL.
In this specific case, the drawable (triangle_right) used in this view
is _not_ autoMirrored which is correct. But the `layout_marginStart`
attribute adds the margin to the wrong side of the view that breaks the
appearance.

c.f. https://material.io/design/usability/bidirectionality.html#mirroring-elements
2021-08-04 09:44:56 -03:00
Cody Henthorne 615fbf87c9 Improve thread update performance by avoiding costly join query. 2021-08-03 14:33:14 -04:00
Alex Hart aca3d150bf Never display unplayed dot in note-to-self.
Fixes #11515
2021-08-03 13:45:30 -03:00
Cody Henthorne 6eae2d39a8 Improve thread update performance by removing need for message count. 2021-08-03 13:45:30 -03:00
Alex Hart c78e283084 Reimplement voice note proximity locking.
Proximity lock was tied to the VoiceNotePlaybackService instead of to the Activity, and it made for some strange code decisions. This rewrite now ties locking to the activity, where it should have been in the first place, and hopefully solves a few proximity / playback bugs on the way. In addition, it conforms to SRP better as it will send a command to the player to change the audio attributes as necessary instead of directly operating on a player instance.
2021-08-03 13:45:30 -03:00
Alex Hart 2d5492ffac Add accessibility descriptions to voice note player view.
Fixes #11518
2021-08-03 13:45:30 -03:00
Cody Henthorne 2830132b24 Reduce time to start PushTextSendJob. 2021-08-03 13:45:30 -03:00
Alex Hart e374f3afe6 Bump version to 5.19.4 2021-08-03 13:34:33 -03:00
Alex Hart 58a2e50904 Updated language translations. 2021-08-03 13:33:55 -03:00
Alex Hart be29dce7b7 Add lint suppression for API31 bluetooth permissions. 2021-08-03 13:33:54 -03:00
Lucio Maciel e58b617689 Revert grouping body+footer 2021-08-03 11:52:22 -03:00
Greyson Parrelli 9d3a9fc675 Bump version to 5.19.3 2021-08-02 16:32:23 -04:00
Greyson Parrelli 8bddf5206c Updated language translations. 2021-08-02 16:24:09 -04:00
Alex Hart 0ac234e7bf Wrap calls in separate checks for ISE so we do as many as possible. 2021-08-02 16:19:43 -04:00
Alex Hart 290f84e5b1 Ensure correct local device rotation information is retained when starting a call. 2021-08-02 16:19:43 -04:00
Alex Hart 149c138666 Fix negative audio message duration. 2021-08-02 16:19:43 -04:00
Cody Henthorne 065a39992a Fix crash when encountering null PendingIntent. 2021-08-02 16:19:43 -04:00
Lucio Maciel 4a52532256 Require CALL_PHONE permission on VoiceCallShare activity.
Thanks to Jouni Hiltunen for the report
2021-08-02 16:19:39 -04:00
Alex Hart 93f541ceca Fix issue where audio messages would hide their footer. 2021-08-02 16:19:39 -04:00
Cody Henthorne e97a1b2cf6 Fix mixed theme use when system force dark is on.
Thanks to flodo from the forum for the help.
2021-08-02 16:19:39 -04:00
Alex Hart f6b46f921c Fix issue where emojis would not appear on app launch. 2021-08-02 16:19:39 -04:00
AsamK 5fef0494b1 Fix crash with untitled sticker pack in sticker keyboard. 2021-08-02 16:19:39 -04:00
Alex Hart 6e1621fef1 Allow content of basic megaphones to scroll.
Fixes #11507
2021-08-02 16:19:39 -04:00
Alex Hart e5f1793eb3 Add content description to navigation button on settings toolbar. 2021-08-02 16:19:39 -04:00
Alex Hart a994712609 Add tint to checkmark in ContactNameEditActivity. 2021-08-02 16:19:39 -04:00
Alex Hart 3b8eac0b8d Disable registration lock toggle and pin reminder toggle if user does not have a pin. 2021-08-02 16:19:39 -04:00
Alex Hart 52978b1b42 Ensure landscape operation is only enabled on foldable displays. 2021-08-02 16:19:39 -04:00
Greyson Parrelli 922d0d7203 Stop showing empty group updates for internal users. 2021-08-01 00:26:20 -04:00
Cody Henthorne 429fdf0d76 Bump version to 5.19.2 2021-07-30 17:39:38 -04:00
Cody Henthorne 1f718009bd Updated language translations. 2021-07-30 17:39:38 -04:00
Greyson Parrelli c1c9ca7c4c Give the service direct knowledge of linked device status. 2021-07-30 17:39:29 -04:00
Greyson Parrelli 75421b1af8 Rebuild list of send targets after sending distribution key. 2021-07-30 13:17:43 -04:00
Greyson Parrelli d40bb2d9ee Clear all sender key knowledge for a device after a 409/410. 2021-07-30 13:17:43 -04:00
Greyson Parrelli 7c8549bf5e Don't unnecessarily create threads for groups. 2021-07-30 12:27:28 -04:00
Cody Henthorne fb8f481a87 Bump version to 5.19.1 2021-07-29 16:52:16 -04:00
Cody Henthorne 8caa690086 Updated language translations. 2021-07-29 16:46:16 -04:00
Greyson Parrelli d7011e3353 Improve clarity around time conversions. 2021-07-29 16:24:20 -04:00
Cody Henthorne 9af966b030 Improve width calculation for span count. 2021-07-29 15:57:57 -04:00
Lucio Maciel a46accfcc0 Fix link preview margins 2021-07-29 16:47:28 -03:00
Lucio Maciel c0c4092cd9 Update view-once messages 2021-07-29 16:46:32 -03:00
Cody Henthorne 9398716848 Improve speed of sending single messages. 2021-07-29 14:07:39 -04:00
Greyson Parrelli 25234496bf Add support for announcement groups. 2021-07-28 17:21:19 -04:00
Cody Henthorne 1a56924a56 Bump version to 5.19.0 2021-07-28 16:05:33 -04:00
Cody Henthorne d168d35362 Updated language translations. 2021-07-28 15:58:12 -04:00
Greyson Parrelli 3cc2cd0b17 Add support for signal.me links. 2021-07-28 11:58:03 -04:00
Lucio Maciel 138b7ea796 Update message bubble and date header timestamps. 2021-07-28 12:39:50 -03:00
Cody Henthorne 1f1a4eb351 Fix incorrect emojis used in Settings. 2021-07-28 09:40:29 -04:00
Lucio Maciel b9081dc942 Update message collapsing criteria 2021-07-27 19:52:28 -03:00
Lucio Maciel e76808a000 Adjust conversation updates margins 2021-07-27 19:40:39 -03:00
Lucio Maciel e31fd8d578 Update timer icons and message bubble margins 2021-07-27 19:37:59 -03:00
Greyson Parrelli 7d8f780d60 Clean up bookkeeping around threads. 2021-07-27 13:52:49 -04:00
Greyson Parrelli 0478757af4 Sync archive status changes after thread updates. 2021-07-27 13:47:15 -04:00
Cody Henthorne 712b0c147a Improve WebSocket health monitoring. 2021-07-27 13:40:33 -04:00
Jim Gustafson fc6db45e59 Update to RingRTC v2.10.7 2021-07-26 13:42:14 -04:00
Alex Hart 5229e24397 Implement initial support for foldables in calling. 2021-07-26 13:42:14 -04:00
Alex Hart 927b6096c6 Upgrade AGP and Gradle. 2021-07-26 13:42:14 -04:00
Greyson Parrelli 16f1128990 Bump version to 5.18.4 2021-07-26 13:31:39 -04:00
Greyson Parrelli 4fd1d05503 Updated language translations. 2021-07-26 13:28:04 -04:00
Greyson Parrelli dfac05a118 Do not use constants in LogDatabase#onUpgrade. 2021-07-26 11:29:25 -04:00
Greyson Parrelli cd869bcb89 Fix name of nightly build. 2021-07-26 11:16:24 -04:00
Greyson Parrelli 427119cef2 Do not backup the avatar picker database. 2021-07-26 10:40:55 -04:00
Lucio Maciel dada7a4f06 Revert "Update timer icons and received text bubble."
This reverts commits 26c9b5166e,
833f90ce53,
0ba7ff911b and 38adb0373d.
2021-07-26 11:13:26 -03:00
Greyson Parrelli 44a84210d8 Fix backup setting summary text consistency. 2021-07-26 10:08:20 -04:00
Greyson Parrelli 5ac8d3b0bd Do not show profile photo when tapping note to self. 2021-07-26 10:00:09 -04:00
Greyson Parrelli 7ccba5b1c8 Handle missing file browser during backup selection. 2021-07-26 09:59:49 -04:00
Greyson Parrelli c2ffd8adbb Fix crash when submitting a debuglog during registration. 2021-07-26 09:39:03 -04:00
Greyson Parrelli 7e4396ae3f Use custom emoji for avatars. 2021-07-26 08:56:20 -04:00
Greyson Parrelli d0827eb48e Fix emoji rendering artifact.
There's sometimes this one pixel line that can appear next to them.
Easiest solution for now is to trim it off.
2021-07-26 08:23:09 -04:00
Greyson Parrelli 90397165c3 Bump version to 5.18.3 2021-07-23 17:58:40 -04:00
Greyson Parrelli e3e47504a6 Updated language translations. 2021-07-23 17:58:18 -04:00
Greyson Parrelli 42269efa57 Fix reaction sizing issue. 2021-07-23 17:53:52 -04:00
Lucio Maciel 38adb0373d Fix mentions and thumbnail size. 2021-07-23 17:53:52 -04:00
Alex Hart 8bde389398 Scroll to selected on state change. 2021-07-23 17:52:51 -04:00
Alex Hart d29b0609a3 Create nicer animation for moving between pages. 2021-07-23 14:02:47 -03:00
Alex Hart 740977164b Apply several fixes for beta feedback.
* Remove overscroll from avatar picker recyclers.
* Center crop wallpaper previews.
* If no media thumb exists, return bubble projection instead.
2021-07-23 13:47:43 -03:00
Greyson Parrelli 2dd8f24e14 Bump version to 5.18.2 2021-07-23 08:27:56 -04:00
Greyson Parrelli 4e409fc9ed Updated language translations. 2021-07-23 08:27:15 -04:00
Greyson Parrelli 136826be69 Update order of onboarding cards. 2021-07-23 08:07:49 -04:00
Lucio Maciel 0ba7ff911b Fix margins on message bubbles. 2021-07-23 08:05:50 -04:00
Alex Hart bfbdbdcbc0 Add Photo onboarding card. 2021-07-23 08:05:28 -04:00
Greyson Parrelli f2533ac4b7 Fallback to legacy sends upon getting a 401 during sender key. 2021-07-23 08:05:28 -04:00
Greyson Parrelli 15a5f5966d Update logging to be size-limited and more performant. 2021-07-23 08:05:28 -04:00
Alex Hart 3c748b2df6 Fix NullPointerException if there is no cursor drawable set. 2021-07-23 08:05:28 -04:00
Alex Hart c1b54b3532 Fix several issues with new avatar picker.
* Fix silliness with text behaviour
* Fix long click behaviour
* Make views play nicer with landscape mode
* Do not show megaphone if user has an avatar (or had one and removed it)
* Fix bad heading on vector color picker
2021-07-23 08:05:28 -04:00
Alex Hart ab56856f41 Adjust sizing of default group icon in chat settings. 2021-07-23 08:05:28 -04:00
Alex Hart ce31e642dd Fix missing background on video player bar. 2021-07-22 11:10:57 -03:00
Greyson Parrelli aa67c82634 Bump version to 5.18.1 2021-07-22 03:04:15 -04:00
Greyson Parrelli c9c4187d2e Updated language translations. 2021-07-22 03:04:15 -04:00
Greyson Parrelli 60b4862b1b Ensure SQLCipher is loaded before logging begins. 2021-07-22 03:04:15 -04:00
Greyson Parrelli b2c3a34d68 Bump version to 5.18.0 2021-07-21 16:57:04 -04:00
Greyson Parrelli 90925f4d8c Updated language translations. 2021-07-21 16:57:04 -04:00
Lucio Maciel 833f90ce53 Fix margins on message clusters and 1:1 messages. 2021-07-21 16:57:04 -04:00
Lucio Maciel 26c9b5166e Update timer icons and received text bubble. 2021-07-21 16:57:04 -04:00
Alex Hart a27d60f830 Adjust new avatar picker logic.
* Better emoji rendering support
* Deleting an avatar will deselect it
* Added padding to the bottom of recyclers
* Disabled save if no edit / selection has been made.
* Clearing and saving will remove a user's avatar.
2021-07-21 16:57:04 -04:00
Alex Hart a75f634c0a Add megaphone for new avatar picker. 2021-07-21 16:57:04 -04:00
lucio-signal 963c018e0c Add SingleLineEmojiTextView to fix flickering on conversations list. 2021-07-21 16:57:04 -04:00
Alex Hart 6cc0eed5fe Fail linked preview thumbnail request instead of crashing app. 2021-07-21 16:57:04 -04:00
Alex Hart cdcc7b6fa5 Fix voice note player crash in Android 4.4 2021-07-21 16:57:04 -04:00
Alex Hart 24482b5a65 Disable conversation overscroll for Android 12. 2021-07-21 16:57:03 -04:00
Alex Hart b100262c6a Fix crash when sending video (due to IllegalStateException). 2021-07-21 16:57:03 -04:00
Alex Hart ed23c3fe7c Add avatar picker and defaults. 2021-07-21 16:57:03 -04:00
Greyson Parrelli 0093e1d3eb Add the ability to increase log lifespan. 2021-07-21 16:57:03 -04:00
Greyson Parrelli 7419da7247 Move logging into a database. 2021-07-21 16:57:03 -04:00
Greyson Parrelli 0b85852621 Bump version to 5.17.3 2021-07-19 13:05:11 -04:00
Greyson Parrelli 556518973d Fix crash during cache warming for fresh installs. 2021-07-19 13:01:53 -04:00
Greyson Parrelli b9514d0b94 Bump version to 5.17.2 2021-07-19 12:40:21 -04:00
Greyson Parrelli a9dab90a1e Updated language translations. 2021-07-19 12:40:21 -04:00
Greyson Parrelli 39709c8d64 Fix some timing issues around recipient events. 2021-07-19 12:40:21 -04:00
Greyson Parrelli c2a6963a6d Warm up some recipients from the contact selection screen. 2021-07-19 11:57:26 -04:00
Greyson Parrelli bfdebbfa5d Sort contacts that start with a number at the end. 2021-07-19 11:57:26 -04:00
Alex Hart 167a691018 Update SMS tag visibility in onRecipientChanged. 2021-07-19 11:57:26 -04:00
Greyson Parrelli 8004565c84 Bump version to 5.17.1 2021-07-16 15:55:54 -04:00
Greyson Parrelli a101dc4fd1 Updated language translations. 2021-07-16 15:53:42 -04:00
Alex Hart 57f730d8ee Fix cursor issue for non-signal-contact searches. 2021-07-16 16:34:38 -03:00
Alex Hart 3543cc80ba Don't show SMS label for push groups. 2021-07-16 16:34:02 -03:00
Greyson Parrelli 71613d9db1 Ensure SQLCipher libraries are loaded. 2021-07-16 14:12:00 -04:00
Greyson Parrelli 4a0e6a3eb2 Improve logging around message sends. 2021-07-16 12:53:29 -04:00
Alex Hart f1a87518e1 Fix contact search query returning outdated or bad recipients. 2021-07-16 13:53:17 -03:00
Alex Hart 61f880fd78 Hide all media for new conversations. 2021-07-16 13:20:30 -03:00
Greyson Parrelli 09904e7a16 Remove GIF button from attachment keyboard.
We've had it there for ~45 days for education purposes, but we can
remove it now.
2021-07-16 11:01:10 -04:00
Alex Hart 94658e9090 Fix bug where marquee text stopped scrolling. 2021-07-16 09:23:47 -03:00
Greyson Parrelli a47448b6c6 Bump version to 5.17.0 2021-07-15 16:41:11 -04:00
Greyson Parrelli 7e4b9b685a Updated language translations. 2021-07-15 16:40:28 -04:00
lucio-signal 64922a8e51 Fix custom vibration settings. 2021-07-15 16:29:43 -04:00
Cody Henthorne f65f4704c9 Improve routine around bulk attachment deletion. 2021-07-15 16:29:11 -04:00
Greyson Parrelli b04ca202f6 Fix ApplicationMigrations UI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli 83086a5a2b Make Sms/MmsDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne 51a521594f Fix crash when deleting threads directly after backup restore. 2021-07-15 16:28:13 -04:00
Greyson Parrelli 0a7a7cf5a9 Fix envelope type conversion. 2021-07-15 16:28:13 -04:00
Greyson Parrelli 6bd689504c Make internal recipient details selectable. 2021-07-15 16:28:13 -04:00
Greyson Parrelli efec40ff57 Fix crash with GV2 group repair during storage sync. 2021-07-15 16:28:13 -04:00
Greyson Parrelli 69716dde4a Fix navigation directly to the help screen. 2021-07-15 16:28:13 -04:00
Greyson Parrelli e90fa05d60 Update recipient merging. 2021-07-15 16:28:13 -04:00
Greyson Parrelli 580c000bda Move distribution message processing into the decryption phase. 2021-07-15 16:28:13 -04:00
lucio-signal 2f3d04d3e8 Add EmojiFilter to SearchView input field. 2021-07-15 16:28:13 -04:00
Greyson Parrelli bf37d412e9 Add message trimming info to the debug log. 2021-07-15 16:28:13 -04:00
Alex Hart fd115ebb72 Drop voice notes that do not have a URI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli b9657208fe Make ThreadDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne 5d6d78a51e Initial WebSocket refactor. 2021-07-15 16:28:13 -04:00
Cody Henthorne 916006e664 Tweak sizes and padding of various keyboard elements. 2021-07-15 16:28:13 -04:00
Cody Henthorne 55c69cd50a Add additional fallback logic for change dialog. 2021-07-15 16:28:13 -04:00
Cody Henthorne 14565b0864 Fix crash when building notification state for messages without threads. 2021-07-15 16:28:13 -04:00
Alex Hart a157c1ae1d Refresh contact search views. 2021-07-15 16:28:13 -04:00
Greyson Parrelli a4d458f969 Use current tag for nightly versionName. 2021-07-15 16:28:13 -04:00
Alex Hart 3f53abedab Migrate to new Share APIs. 2021-07-15 16:28:13 -04:00
Jordan Rose 68a2d5ed20 Reimplement ProfileCipherInputStream using libsignal-client.
libsignal-client provides an AES-GCM streaming interface that can
replace the implementation in AES-GCM-Provider. Using it from
ProfileCipherInputStream requires some knowledge about the tag size of
AES-GCM, but frees it from the JCE interface.

Note that it remains a serious error to not read the *entire* stream,
since the authentication tag is at the end!
2021-07-15 16:28:11 -04:00
Jordan Rose 35e9e31a7b Update to libsignal-client 0.8.3
This also fixes a misalignment where signal-client-android was on
0.8.0 but signal-client-java was 0.8.1, which was fortunately harmless
for this particular pair of versions.
2021-07-12 20:29:07 -04:00
Cody Henthorne 444d947743 Add RxJava. 2021-07-12 20:29:07 -04:00
1252 changed files with 64133 additions and 23063 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ jobs:
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Remove Android S
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-S"
- name: Remove Android 31 (S)
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
- name: Build with Gradle
run: ./gradlew qa
+3 -3
View File
@@ -4,7 +4,7 @@ Signal is a messaging app for simple private communication with friends.
Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone.
Currently available on the Play store.
Currently available on the Play store and [signal.org](https://signal.org/android/apk/).
<a href='https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' height='80px'/></a>
@@ -59,8 +59,8 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2020 Signal
Copyright 2013-2021 Signal
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
Google Play and the Google Play logo are trademarks of Google Inc.
Google Play and the Google Play logo are trademarks of Google LLC.
+92 -90
View File
@@ -1,7 +1,3 @@
import org.signal.signing.ApkSignerUtil
import java.security.MessageDigest
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
@@ -27,6 +23,12 @@ repositories {
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
}
}
maven {
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
content {
includeGroupByRegex "org\\.signal.*"
}
}
maven { // textdrawable
url 'https://dl.bintray.com/amulyakhare/maven'
content {
@@ -35,11 +37,23 @@ repositories {
}
google()
mavenCentral()
jcenter()
mavenLocal()
maven {
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
}
jcenter {
content {
includeVersion "com.google.android.exoplayer", "exoplayer-core", "2.9.1"
includeVersion "com.google.android.exoplayer", "exoplayer-ui", "2.9.1"
includeVersion "com.google.android.exoplayer", "extension-mediasession", "2.9.1"
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
includeVersion "cn.carbswang.android", "NumberPickerView", "1.0.9"
includeVersion "com.takisoft.fix", "colorpicker", "0.9.1"
includeVersion "com.codewaves.stickyheadergrid", "stickyheadergrid", "0.9.4"
includeVersion "com.amulyakhare", "com.amulyakhare.textdrawable", "1.0.1"
includeVersion "com.google.android", "flexbox", "0.3.0"
}
}
}
protobuf {
@@ -57,8 +71,8 @@ protobuf {
}
}
def canonicalVersionCode = 877
def canonicalVersionName = "5.16.3"
def canonicalVersionCode = 912
def canonicalVersionName = "5.23.0"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -69,6 +83,31 @@ def abiPostFix = ['universal' : 0,
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
def selectableVariants = [
'internalProdFlipper',
'internalProdPerf',
'internalProdRelease',
'internalStagingFlipper',
'internalStagingPerf',
'internalStagingRelease',
'nightlyProdFlipper',
'nightlyProdPerf',
'nightlyProdRelease',
'nightlyStagingPerf',
'playProdDebug',
'playProdFlipper',
'playProdPerf',
'playProdRelease',
'playStagingDebug',
'playStagingFlipper',
'playStagingPerf',
'playStagingRelease',
'studyProdMock',
'studyProdPerf',
'websiteProdFlipper',
'websiteProdRelease',
]
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
@@ -110,7 +149,7 @@ android {
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service.whispersystems.org\""
buildConfigField "String", "SIGNAL_URL", "\"https://chat.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
@@ -133,7 +172,7 @@ android {
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44}"
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44,49,33,41}"
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
@@ -264,11 +303,11 @@ android {
nightly {
dimension 'distribution'
versionNameSuffix "-nightly-${getDateSuffix()}"
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
}
study {
@@ -295,7 +334,7 @@ android {
applicationIdSuffix ".staging"
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service-staging.whispersystems.org\""
buildConfigField "String", "SIGNAL_URL", "\"https://chat.staging.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
@@ -303,7 +342,7 @@ android {
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
"\"51a56084c0b21c6b8f62b1bc792ec9bedac4c7c3964bb08ddcab868158c09982\", " +
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
@@ -319,6 +358,10 @@ android {
variant.outputs.each { output ->
if (output.baseName.contains('nightly')) {
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
def tag = getCurrentGitTag()
if (tag != null && tag.length() > 0) {
output.versionNameOverride = tag
}
} else {
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
def abiName = output.getFilter("ABI") ?: 'universal'
@@ -335,16 +378,9 @@ android {
def distribution = variant.getFlavors().get(0).name
def environment = variant.getFlavors().get(1).name
def buildType = variant.buildType.name
def fullName = distribution + environment.capitalize() + buildType.capitalize()
if (distribution == 'study' && buildType != 'perf' && buildType != 'mock') {
variant.setIgnore(true)
} else if (distribution != 'study' && buildType == 'mock') {
variant.setIgnore(true)
} else if (distribution == 'internal' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
variant.setIgnore(true)
} else if (distribution == 'nightly' && environment != 'prod') {
variant.setIgnore(true)
} else if (distribution == 'nightly' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
if (!selectableVariants.contains(fullName)) {
variant.setIgnore(true)
}
}
@@ -363,7 +399,8 @@ android {
}
dependencies {
implementation 'androidx.fragment:fragment-ktx:1.2.5'
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.fragment:fragment-ktx:1.3.5'
lintChecks project(':lintchecks')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
@@ -371,6 +408,7 @@ dependencies {
implementation ('androidx.appcompat:appcompat:1.2.0') {
force = true
}
implementation "androidx.window:window:1.0.0-alpha09"
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
@@ -386,6 +424,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0'
implementation 'androidx.lifecycle:lifecycle-reactivestreams-ktx:2.1.0'
implementation "androidx.camera:camera-core:1.0.0-beta11"
implementation "androidx.camera:camera-camera2:1.0.0-beta11"
implementation "androidx.camera:camera-lifecycle:1.0.0-beta11"
@@ -393,6 +432,7 @@ dependencies {
implementation "androidx.concurrent:concurrent-futures:1.0.0"
implementation "androidx.autofill:autofill:1.0.0"
implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.sharetarget:sharetarget:1.1.0"
implementation ('com.google.firebase:firebase-messaging:22.0.0') {
exclude group: 'com.google.firebase', module: 'firebase-core'
@@ -403,9 +443,9 @@ dependencies {
implementation 'com.google.android.gms:play-services-maps:16.1.0'
implementation 'com.google.android.gms:play-services-auth:16.0.1'
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
implementation 'com.google.android.exoplayer:extension-mediasession:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
implementation 'org.conscrypt:conscrypt-android:2.0.0'
implementation 'org.signal:aesgcmprovider:0.0.3'
@@ -417,7 +457,7 @@ dependencies {
implementation project(':device-transfer')
implementation 'org.signal:zkgroup-android:0.7.0'
implementation 'org.whispersystems:signal-client-android:0.8.0'
implementation "org.whispersystems:signal-client-android:${LIBSIGNAL_CLIENT_VERSION}"
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
implementation('com.mobilecoin:android-sdk:1.1.0') {
@@ -426,7 +466,7 @@ dependencies {
implementation 'org.signal:argon2:13.1@aar'
implementation 'org.signal:ringrtc-android:2.10.6'
implementation 'org.signal:ringrtc-android:2.11.1'
implementation "me.leolin:ShortcutBadger:1.1.22"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
@@ -469,7 +509,7 @@ dependencies {
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation "net.zetetic:android-database-sqlcipher:4.4.3"
implementation 'org.signal:android-database-sqlcipher:4.4.3-S2'
implementation "androidx.sqlite:sqlite:2.1.0"
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
@@ -503,73 +543,15 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0"
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'io.reactivex.rxjava3:rxkotlin:3.0.1'
}
dependencyVerification {
configuration = '(play|website)(Prod|Staging)(Debug|Release)RuntimeClasspath'
}
def assembleWebsiteDescriptor = { variant, file ->
if (file.exists()) {
MessageDigest md = MessageDigest.getInstance("SHA-256");
file.eachByte 4096, {bytes, size ->
md.update(bytes, 0, size);
}
String digest = md.digest().collect {String.format "%02x", it}.join();
String url = variant.productFlavors.get(0).ext.websiteUpdateUrl
String apkName = file.getName()
String descriptor = "{" +
"\"versionCode\" : ${canonicalVersionCode * postFixSize + abiPostFix['universal']}," +
"\"versionName\" : \"$canonicalVersionName\"," +
"\"sha256sum\" : \"$digest\"," +
"\"url\" : \"$url/$apkName\"" +
"}"
File descriptorFile = new File(file.getParent(), apkName.replace(".apk", ".json"))
descriptorFile.write(descriptor)
}
}
def signProductionRelease = { variant ->
variant.outputs.collect { output ->
String apkName = output.outputFile.name
File inputFile = new File(output.outputFile.path)
File outputFile = new File(output.outputFile.parent, apkName.replace('-unsigned', ''))
new ApkSignerUtil('sun.security.pkcs11.SunPKCS11',
'pkcs11.config',
'PKCS11',
'file:pkcs11.password').calculateSignature(inputFile.getAbsolutePath(),
outputFile.getAbsolutePath())
inputFile.delete()
outputFile
}
}
task signProductionPlayRelease {
doLast {
signProductionRelease(android.applicationVariants.find { (it.name == 'playProdRelease') })
}
}
task signProductionInternalRelease {
doLast {
signProductionRelease(android.applicationVariants.find { (it.name == 'internalProdRelease') })
}
}
task signProductionWebsiteRelease {
doLast {
def variant = android.applicationVariants.find { (it.name == 'websiteProdRelease') }
File signedRelease = signProductionRelease(variant).find { it.name.contains('universal') }
assembleWebsiteDescriptor(variant, signedRelease)
}
}
def getLastCommitTimestamp() {
if (!(new File('.git').exists())) {
return System.currentTimeMillis().toString()
@@ -599,6 +581,26 @@ def getGitHash() {
return stdout.toString().trim()
}
def getCurrentGitTag() {
if (!(new File('.git').exists())) {
return ''
}
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'tag', '--points-at', 'HEAD'
standardOutput = stdout
}
def output = stdout.toString().trim()
if (output != null && output.size() > 0) {
return output.split('\n')[0];
} else {
return null
}
}
tasks.withType(Test) {
testLogging {
events "failed"
+4 -1
View File
@@ -2,4 +2,7 @@
-keep class org.sqlite.database.** { *; }
-keep class net.sqlcipher.** { *; }
-dontwarn net.sqlcipher.**
-dontwarn net.sqlcipher.**
-keep class net.zetetic.** { *; }
-dontwarn net.zetetic.**
+2
View File
@@ -9,3 +9,5 @@
# Protobuf lite
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-keep class androidx.window.** { *; }
@@ -11,9 +11,9 @@ import androidx.annotation.Nullable;
import com.facebook.flipper.plugins.databases.DatabaseDescriptor;
import com.facebook.flipper.plugins.databases.DatabaseDriver;
import net.sqlcipher.DatabaseUtils;
import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteStatement;
import net.zetetic.database.DatabaseUtils;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteStatement;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@@ -50,11 +50,13 @@ public class FlipperSqlCipherAdapter extends DatabaseDriver<FlipperSqlCipherAdap
SignalDatabase keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext());
SignalDatabase megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext());
SignalDatabase jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext());
SignalDatabase metricsOpenHelper = LocalMetricsDatabase.getInstance((Application) getContext());
return Arrays.asList(new Descriptor(mainOpenHelper),
new Descriptor(keyValueOpenHelper),
new Descriptor(megaphoneOpenHelper),
new Descriptor(jobManagerOpenHelper));
new Descriptor(jobManagerOpenHelper),
new Descriptor(metricsOpenHelper));
} catch (Exception e) {
Log.i(TAG, "Unable to use reflection to access raw database.", e);
}
+28 -17
View File
@@ -96,6 +96,7 @@
android:label="@string/app_name"
android:supportsRtl="true"
tools:replace="android:allowBackup"
android:resizeableActivity="true"
android:allowBackup="false"
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
@@ -104,6 +105,9 @@
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
<meta-data android:name="android.supports_size_changes"
android:value="true" />
<meta-data android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version" />
@@ -117,16 +121,15 @@
<activity android:name=".WebRtcCallActivity"
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
android:excludeFromRecents="true"
android:screenOrientation="portrait"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:taskAffinity=".calling"
android:resizeableActivity="true"
android:launchMode="singleTask"/>
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
android:theme="@style/TextSecure.DarkNoActionBar"
android:screenOrientation="portrait"
android:noHistory="true"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
@@ -198,7 +201,7 @@
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value=".service.DirectShareService" />
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
@@ -240,6 +243,9 @@
<meta-data android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher" />
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity android:name=".deeplinks.DeepLinkEntryActivity"
@@ -272,6 +278,16 @@
<data android:scheme="sgnl"
android:host="signal.tube" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="signal.me" />
<data android:scheme="sgnl"
android:host="signal.me" />
</intent-filter>
</activity>
<activity android:name=".conversation.ConversationActivity"
@@ -302,7 +318,8 @@
<activity android:name=".messagedetails.MessageDetailsActivity"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity android:name=".groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
@@ -319,7 +336,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".migrations.ApplicationMigrationActivity"
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
@@ -350,11 +367,11 @@
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediasend.MediaSendActivity"
android:theme="@style/TextSecure.FullScreenMedia"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTop"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediasend.v2.MediaSelectionActivity"
android:theme="@style/TextSecure.FullScreenMedia"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:launchMode="singleTop"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".PassphraseChangeActivity"
android:label="@string/AndroidManifest__change_passphrase"
@@ -478,6 +495,7 @@
<activity android:name="org.thoughtcrime.securesms.webrtc.VoiceCallShare"
android:excludeFromRecents="true"
android:permission="android.permission.CALL_PHONE"
android:theme="@style/NoAnimation.Theme.BlackScreen"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
@@ -651,13 +669,6 @@
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contactsformat" />
</service>
<service android:name=".service.DirectShareService"
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE">
<intent-filter>
<action android:name="android.service.chooser.ChooserTargetService" />
</intent-filter>
</service>
<service android:name=".service.GenericForegroundService"/>
<service android:name=".gcm.FcmFetchService" />
Binary file not shown.
@@ -174,6 +174,11 @@ public final class SignalCameraView extends FrameLayout {
private void init(Context context, @Nullable AttributeSet attrs) {
addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */);
// Begin custom signal code block
mPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
// End custom signal code block
mCameraModule = new SignalCameraXModule(this);
if (attrs != null) {
@@ -222,17 +222,10 @@ final class SignalCameraXModule {
// End Signal Custom Code Block
Rational targetAspectRatio;
if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
// Begin Signal Custom Code Block
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait));
// End Signal Custom Code Block
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3;
} else {
// Begin Signal Custom Code Block
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
// End Signal Custom Code Block
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
}
// Begin Signal Custom Code Block
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
// End Signal Custom Code Block
// Begin Signal Custom Code Block
mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.whispersystems.signalservice.api.account.AccountAttributes;
public final class AppCapabilities {
@@ -8,15 +7,17 @@ public final class AppCapabilities {
private AppCapabilities() {
}
private static final boolean UUID_CAPABLE = false;
private static final boolean GV2_CAPABLE = true;
private static final boolean GV1_MIGRATION = true;
private static final boolean UUID_CAPABLE = false;
private static final boolean GV2_CAPABLE = true;
private static final boolean GV1_MIGRATION = true;
private static final boolean ANNOUNCEMENT_GROUPS = true;
private static final boolean SENDER_KEY = true;
/**
* @param storageCapable Whether or not the user can use storage service. This is another way of
* asking if the user has set a Signal PIN or not.
*/
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, FeatureFlags.senderKey());
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS);
}
}
@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.Build;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -32,11 +33,13 @@ import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.PersistentLogger;
import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
@@ -53,7 +56,7 @@ import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.LogSecretProvider;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
@@ -71,9 +74,9 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
@@ -86,6 +89,9 @@ import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
import java.security.Security;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Will be called once when the TextSecure process is created.
*
@@ -108,6 +114,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
public void onCreate() {
Tracer.getInstance().start("Application#onCreate()");
AppStartup.getInstance().onApplicationCreate();
SignalLocalMetrics.ColdStart.start();
long startTime = System.currentTimeMillis();
@@ -118,12 +125,16 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
super.onCreate();
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
.addBlocking("logging", () -> {
initializeLogging();
Log.i(TAG, "onCreate()");
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("eat-db", () -> DatabaseFactory.getInstance(this))
.addBlocking("rx-init", () -> {
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
})
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
@@ -146,6 +157,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
})
.addBlocking("blob-provider", this::initializeBlobProvider)
.addBlocking("feature-flags", FeatureFlags::init)
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
.addNonBlocking(this::initializeGcmCheck)
@@ -168,6 +180,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
SignalLocalMetrics.ColdStart.onApplicationCreateFinished();
Tracer.getInstance().end("Application#onCreate()");
}
@@ -238,10 +251,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void initializeLogging() {
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
persistentLogger = new PersistentLogger(this);
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
SignalExecutors.UNBOUNDED.execute(() -> LogDatabase.getInstance(this).trimToSize());
}
private void initializeCrashHandling() {
@@ -369,6 +384,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
BlobProvider.getInstance().initialize(this);
}
@WorkerThread
private void cleanAvatarStorage() {
AvatarPickerStorage.cleanOrphans(this);
}
@WorkerThread
private void initializeCleanup() {
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();
@@ -27,6 +27,7 @@ import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
@@ -71,12 +72,14 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
}
Toolbar toolbar = findViewById(R.id.toolbar);
ImageView avatar = findViewById(R.id.avatar);
Toolbar toolbar = findViewById(R.id.toolbar);
EmojiTextView title = findViewById(R.id.title);
ImageView avatar = findViewById(R.id.avatar);
setSupportActionBar(toolbar);
requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
requireSupportActionBar().setDisplayShowTitleEnabled(false);
Context context = getApplicationContext();
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
@@ -122,7 +125,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
}
});
toolbar.setTitle(recipient.getDisplayName(context));
title.setText(recipient.getDisplayName(context));
});
FullscreenHelper fullscreenHelper = new FullscreenHelper(this);
@@ -13,6 +13,8 @@ import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
@@ -24,31 +26,29 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable {
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable, Multiselectable {
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ConversationMessage messageRecord,
@NonNull Optional<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Set<MultiselectPart> batchSelected,
@NonNull Recipient recipients,
@Nullable String searchQuery,
boolean pulseMention,
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean canPlayInline,
@NonNull Colorizer colorizer);
ConversationMessage getConversationMessage();
@NonNull ConversationMessage getConversationMessage();
void setEventListener(@Nullable EventListener listener);
@@ -70,6 +70,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
void onIncomingIdentityMismatchClicked(@NonNull RecipientId recipientId);
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onVoiceNotePause(@NonNull Uri uri);
@@ -1,176 +0,0 @@
package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.DialogInterface;
import android.os.AsyncTask;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.VerifySpan;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import java.io.IOException;
public class ConfirmIdentityDialog extends AlertDialog {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ConfirmIdentityDialog.class);
private OnClickListener callback;
public ConfirmIdentityDialog(Context context,
MessageRecord messageRecord,
IdentityKeyMismatch mismatch)
{
super(context);
Recipient recipient = Recipient.resolved(mismatch.getRecipientId(context));
String name = recipient.getDisplayName(context);
String introduction = context.getString(R.string.ConfirmIdentityDialog_your_safety_number_with_s_has_changed, name, name);
SpannableString spannableString = new SpannableString(introduction + " " +
context.getString(R.string.ConfirmIdentityDialog_you_may_wish_to_verify_your_safety_number_with_this_contact));
spannableString.setSpan(new VerifySpan(context, mismatch),
introduction.length()+1, spannableString.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
setTitle(name);
setMessage(spannableString);
setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.ConfirmIdentityDialog_accept), new AcceptListener(messageRecord, mismatch, recipient.getId()));
setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel), new CancelListener());
}
@Override
public void show() {
super.show();
((TextView)this.findViewById(android.R.id.message))
.setMovementMethod(LinkMovementMethod.getInstance());
}
public void setCallback(OnClickListener callback) {
this.callback = callback;
}
private class AcceptListener implements OnClickListener {
private final MessageRecord messageRecord;
private final IdentityKeyMismatch mismatch;
private final RecipientId recipientId;
private AcceptListener(MessageRecord messageRecord, IdentityKeyMismatch mismatch, RecipientId recipientId) {
this.messageRecord = messageRecord;
this.mismatch = mismatch;
this.recipientId = recipientId;
}
@SuppressLint("StaticFieldLeak")
@Override
public void onClick(DialogInterface dialog, int which) {
new AsyncTask<Void, Void, Void>()
{
@Override
protected Void doInBackground(Void... params) {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(Recipient.resolved(recipientId).requireServiceId(), 1);
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(getContext());
identityKeyStore.saveIdentity(mismatchAddress, mismatch.getIdentityKey(), true);
}
processMessageRecord(messageRecord);
return null;
}
private void processMessageRecord(MessageRecord messageRecord) {
if (messageRecord.isOutgoing()) processOutgoingMessageRecord(messageRecord);
else processIncomingMessageRecord(messageRecord);
}
private void processOutgoingMessageRecord(MessageRecord messageRecord) {
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext());
if (messageRecord.isMms()) {
mmsDatabase.removeMismatchedIdentity(messageRecord.getId(),
mismatch.getRecipientId(getContext()),
mismatch.getIdentityKey());
if (messageRecord.getRecipient().isPushGroup()) {
MessageSender.resendGroupMessage(getContext(), messageRecord, Recipient.resolved(mismatch.getRecipientId(getContext())).getId());
} else {
MessageSender.resend(getContext(), messageRecord);
}
} else {
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
mismatch.getRecipientId(getContext()),
mismatch.getIdentityKey());
MessageSender.resend(getContext(), messageRecord);
}
}
private void processIncomingMessageRecord(MessageRecord messageRecord) {
try {
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
mismatch.getRecipientId(getContext()),
mismatch.getIdentityKey());
boolean legacy = !messageRecord.isContentBundleKeyExchange();
SignalServiceEnvelope envelope = new SignalServiceEnvelope(SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE,
Optional.of(RecipientUtil.toSignalServiceAddress(getContext(), messageRecord.getIndividualRecipient())),
messageRecord.getRecipientDeviceId(),
messageRecord.getDateSent(),
legacy ? Base64.decode(messageRecord.getBody()) : null,
!legacy ? Base64.decode(messageRecord.getBody()) : null,
0,
0,
null);
ApplicationDependencies.getJobManager().add(new PushDecryptMessageJob(getContext(), envelope, messageRecord.getId()));
} catch (IOException e) {
throw new AssertionError(e);
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
if (callback != null) callback.onClick(null, 0);
}
}
private class CancelListener implements OnClickListener {
@Override
public void onClick(DialogInterface dialog, int which) {
if (callback != null) callback.onClick(null, 0);
}
}
}
@@ -20,13 +20,13 @@ import android.content.Context;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.appcompat.widget.Toolbar;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
@@ -36,6 +36,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.function.Consumer;
/**
* Base activity container for selecting a list of contacts.
@@ -56,7 +57,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
protected ContactSelectionListFragment contactsFragment;
private ContactFilterToolbar toolbar;
private Toolbar toolbar;
private ContactFilterView contactFilterView;
@Override
protected void onPreCreate() {
@@ -73,6 +75,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
initializeContactFilterView();
initializeToolbar();
initializeResources();
initializeSearch();
@@ -84,16 +87,23 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
dynamicTheme.onResume(this);
}
protected ContactFilterToolbar getToolbar() {
protected Toolbar getToolbar() {
return toolbar;
}
protected ContactFilterView getContactFilterView() {
return contactFilterView;
}
private void initializeContactFilterView() {
this.contactFilterView = findViewById(R.id.contact_filter_edit_text);
}
private void initializeToolbar() {
this.toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
getSupportActionBar().setDisplayShowTitleEnabled(false);
getSupportActionBar().setIcon(null);
getSupportActionBar().setLogo(null);
}
@@ -104,7 +114,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
private void initializeSearch() {
toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
contactFilterView.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
}
@Override
@@ -113,8 +123,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
return true;
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
callback.accept(true);
}
@Override
@@ -155,7 +165,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
ContactSelectionActivity activity = this.activity.get();
if (activity != null && !activity.isFinishing()) {
activity.toolbar.clear();
activity.contactFilterView.clear();
activity.contactsFragment.resetQueryFilter();
}
}
@@ -26,6 +26,7 @@ import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -37,6 +38,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
@@ -53,17 +55,19 @@ import androidx.transition.TransitionManager;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.components.emoji.WarningTextView;
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChip;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.groups.SelectionLimits;
@@ -74,7 +78,6 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -88,6 +91,7 @@ import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
/**
* Fragment for selecting a one or more contacts from a list.
@@ -131,9 +135,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private ChipGroup chipGroup;
private HorizontalScrollView chipGroupScrollContainer;
private WarningTextView groupLimit;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private View shadowView;
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
@Nullable private FixedViewsAdapter headerAdapter;
@@ -175,12 +180,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
}
if (getParentFragment() instanceof OnSelectionLimitReachedListener) {
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
}
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
}
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
}
}
@@ -233,9 +242,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
showContactsProgress = view.findViewById(R.id.progress);
chipGroup = view.findViewById(R.id.chipGroup);
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
groupLimit = view.findViewById(R.id.group_limit);
constraintLayout = view.findViewById(R.id.container);
shadowView = view.findViewById(R.id.toolbar_shadow);
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override
@@ -256,7 +268,9 @@ public final class ContactSelectionListFragment extends LoggingFragment
recyclerView.setClipToPadding(recyclerViewClipping);
swipeRefresh.setEnabled(arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true)));
boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true));
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
swipeRefresh.setEnabled(isRefreshable);
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
@@ -272,8 +286,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
currentSelection = getCurrentSelection();
updateGroupLimit(getChipCount());
return view;
}
@@ -281,13 +293,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
return getArguments() != null ? getArguments() : new Bundle();
}
private void updateGroupLimit(int chipCount) {
int members = currentSelection.size() + chipCount;
groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members));
groupLimit.setVisibility(isMulti && !hideCount ? View.VISIBLE : View.GONE);
groupLimit.setWarning(selectionWarningLimitExceeded());
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -309,6 +314,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
return cursorRecyclerViewAdapter.getSelectedContactsCount();
}
public int getTotalMemberCount() {
if (cursorRecyclerViewAdapter == null) {
return 0;
}
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
}
private Set<RecipientId> getCurrentSelection() {
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
if (currentSelection == null) {
@@ -349,8 +362,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
concatenateAdapter.addAdapter(footerAdapter);
}
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(concatenateAdapter);
recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true, 0));
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
@@ -361,6 +374,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
});
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
}
private boolean hideLetterHeaders() {
return hasQueryFilter() || shouldDisplayRecents();
}
private View createInviteActionView(@NonNull ListCallback listCallback) {
@@ -425,11 +446,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
FragmentActivity activity = requireActivity();
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
boolean displayRecents = safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false));
boolean displayRecents = shouldDisplayRecents();
if (cursorFactoryProvider != null) {
return cursorFactoryProvider.get().create();
@@ -475,6 +500,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
fastScroller.setVisibility(View.GONE);
}
private boolean shouldDisplayRecents() {
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
}
@SuppressLint("StaticFieldLeak")
private void handleContactPermissionGranted() {
final Context context = requireContext();
@@ -551,28 +580,32 @@ public final class ContactSelectionListFragment extends LoggingFragment
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
if (onContactSelectedListener != null) {
if (onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null)) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
if (allowed) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.show();
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.show();
}
});
} else {
if (onContactSelectedListener != null) {
if (onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber())) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
if (allowed) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
@@ -606,12 +639,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
removeChipForContact(selectedContact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
}
private void removeChipForContact(@NonNull SelectedContact contact) {
@@ -622,8 +662,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
updateGroupLimit(getChipCount());
if (getChipCount() == 0) {
setChipGroupVisibility(ConstraintSet.GONE);
}
@@ -660,6 +698,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
@Override
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
if (getView() == null || !requireView().isAttachedToWindow()) {
Log.w(TAG, "Fragment's view was detached before the animation completed.");
return;
}
if (view == chip && transitionType == LayoutTransition.APPEARING) {
chipGroup.getLayoutTransition().removeTransitionListener(this);
registerChipRecipientObserver(chip, recipient.live());
@@ -673,7 +716,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
private void addChip(@NonNull ContactChip chip) {
chipGroup.addView(chip);
updateGroupLimit(getChipCount());
if (selectionWarningLimitReachedExactly()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
@@ -723,9 +765,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public interface OnContactSelectedListener {
/** @return True if the contact is allowed to be selected, otherwise false. */
boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number);
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback);
void onContactDeselected(Optional<RecipientId> recipientId, String number);
void onSelectionChanged();
}
public interface OnSelectionLimitReachedListener {
@@ -150,6 +150,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
});
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -3,12 +3,8 @@ package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.PorterDuff;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
@@ -16,16 +12,16 @@ import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.AnimRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -36,27 +32,25 @@ import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.concurrent.ExecutionException;
import java.util.function.Consumer;
public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener {
private ContactSelectionListFragment contactsFragment;
private EditText inviteText;
private ViewGroup smsSendFrame;
private Button smsSendButton;
private Animation slideInAnimation;
private Animation slideOutAnimation;
private DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
private Toolbar primaryToolbar;
private ContactSelectionListFragment contactsFragment;
private EditText inviteText;
private ViewGroup smsSendFrame;
private Button smsSendButton;
private Animation slideInAnimation;
private Animation slideOutAnimation;
private final DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
@Override
protected void onPreCreate() {
@@ -84,7 +78,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
private void initializeAppBar() {
primaryToolbar = findViewById(R.id.toolbar);
final Toolbar primaryToolbar = findViewById(R.id.toolbar);
setSupportActionBar(primaryToolbar);
assert getSupportActionBar() != null;
@@ -98,9 +92,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
slideOutAnimation = loadAnimation(R.anim.slide_to_bottom);
View shareButton = findViewById(R.id.share_button);
Button smsButton = findViewById(R.id.sms_button);
TextView shareText = findViewById(R.id.share_text);
View smsButton = findViewById(R.id.sms_button);
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
ContactFilterToolbar contactFilter = findViewById(R.id.contact_filter);
ContactFilterView contactFilter = findViewById(R.id.contact_filter_edit_text);
inviteText = findViewById(R.id.invite_text);
smsSendFrame = findViewById(R.id.sms_send_frame);
@@ -121,15 +116,14 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
smsSendButton.setOnClickListener(new SmsSendClickListener());
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
contactFilter.setNavigationIcon(R.drawable.ic_search_conversation_24);
if (Util.isDefaultSmsProvider(this)) {
shareButton.setOnClickListener(new ShareClickListener());
smsButton.setOnClickListener(new SmsClickListener());
} else {
shareButton.setVisibility(View.GONE);
smsButton.setOnClickListener(new ShareClickListener());
smsButton.setText(R.string.InviteActivity_share);
smsButton.setVisibility(View.GONE);
shareText.setText(R.string.InviteActivity_share);
shareButton.setOnClickListener(new ShareClickListener());
}
}
@@ -140,9 +134,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
return true;
callback.accept(true);
}
@Override
@@ -150,6 +144,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
}
@Override
public void onSelectionChanged() {
}
private void sendSmsInvites() {
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
@@ -158,9 +156,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
private void updateSmsButtonText(int count) {
smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends,
count,
count));
smsSendButton.setText(getResources().getString(R.string.InviteActivity_send_sms, count));
smsSendButton.setEnabled(count > 0);
}
@@ -172,43 +168,21 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
}
@Override public boolean onSupportNavigateUp() {
if (smsSendFrame.getVisibility() == View.VISIBLE) {
cancelSmsSelection();
return false;
} else {
return super.onSupportNavigateUp();
}
}
private void cancelSmsSelection() {
setPrimaryColorsToolbarNormal();
contactsFragment.reset();
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE);
}
private void setPrimaryColorsToolbarNormal() {
primaryToolbar.setBackgroundColor(0);
primaryToolbar.getNavigationIcon().setColorFilter(null);
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_primary));
if (Build.VERSION.SDK_INT >= 23) {
WindowUtil.setStatusBarColor(getWindow(), ThemeUtil.getThemedColor(this, android.R.attr.statusBarColor));
getWindow().setNavigationBarColor(ThemeUtil.getThemedColor(this, android.R.attr.navigationBarColor));
WindowUtil.setLightStatusBarFromTheme(this);
}
WindowUtil.setLightNavigationBarFromTheme(this);
}
private void setPrimaryColorsToolbarForSms() {
primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.core_ultramarine));
primaryToolbar.getNavigationIcon().setColorFilter(ContextCompat.getColor(this, R.color.signal_text_toolbar_subtitle), PorterDuff.Mode.SRC_IN);
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_toolbar_title));
if (Build.VERSION.SDK_INT >= 23) {
WindowUtil.setStatusBarColor(getWindow(), ContextCompat.getColor(this, R.color.core_ultramarine));
WindowUtil.clearLightStatusBar(getWindow());
}
if (Build.VERSION.SDK_INT >= 27) {
getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.core_ultramarine));
WindowUtil.clearLightNavigationBar(getWindow());
}
}
private class ShareClickListener implements OnClickListener {
@Override
public void onClick(View v) {
@@ -227,7 +201,6 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
private class SmsClickListener implements OnClickListener {
@Override
public void onClick(View v) {
setPrimaryColorsToolbarForSms();
ViewUtil.animateIn(smsSendFrame, slideInAnimation);
}
}
@@ -279,7 +252,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
Recipient recipient = Recipient.resolved(recipientId);
int subscriptionId = recipient.getDefaultSubscriptionId().or(-1);
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null);
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null, null);
if (recipient.getContactUri() != null) {
DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId());
@@ -49,6 +49,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
handleGroupLinkInIntent(getIntent());
handleProxyInIntent(getIntent());
handleSignalMeIntent(getIntent());
CachedInflater.from(this).clear();
}
@@ -65,6 +66,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
super.onNewIntent(intent);
handleGroupLinkInIntent(intent);
handleProxyInIntent(intent);
handleSignalMeIntent(intent);
}
@Override
@@ -115,6 +117,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
private void handleSignalMeIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString());
}
}
@Override
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
return mediaController;
@@ -171,6 +171,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
initializeObservers();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.function.Consumer;
/**
* Activity container for starting a new conversation.
@@ -56,10 +57,11 @@ public class NewConversationActivity extends ContactSelectionActivity
super.onCreate(bundle, ready);
assert getSupportActionBar() != null;
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {
@@ -93,7 +95,11 @@ public class NewConversationActivity extends ContactSelectionActivity
}
}
return true;
callback.accept(true);
}
@Override
public void onSelectionChanged() {
}
private void launch(Recipient recipient) {
@@ -35,7 +35,6 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.BounceInterpolator;
import android.view.animation.TranslateAnimation;
@@ -51,6 +50,7 @@ import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricManager.Authenticators;
import androidx.biometric.BiometricPrompt;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.AnimatingToggle;
@@ -98,13 +98,16 @@ public class PassphrasePromptActivity extends PassphraseActivity {
private boolean hadFailure;
private boolean alreadyShown;
private final Runnable resumeScreenLockRunnable = () -> {
resumeScreenLock(!alreadyShown);
alreadyShown = true;
};
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate()");
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
super.onCreate(savedInstanceState);
setContentView(R.layout.prompt_passphrase_activity);
@@ -129,11 +132,20 @@ public class PassphrasePromptActivity extends PassphraseActivity {
setLockTypeVisibility();
if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) {
resumeScreenLock(!alreadyShown);
alreadyShown = true;
ThreadUtil.postToMain(resumeScreenLockRunnable);
}
hadFailure = false;
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
}
@Override
public void onPause() {
super.onPause();
ThreadUtil.cancelRunnableOnMain(resumeScreenLockRunnable);
biometricPrompt.cancelAuthentication();
}
@Override
@@ -388,9 +400,6 @@ public class PassphrasePromptActivity extends PassphraseActivity {
@Override
public void onAnimationEnd(Animator animation) {
handleAuthenticated();
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
}
}).start();
}
@@ -412,7 +421,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
@Override
public void onAnimationEnd(Animation animation) {
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
}
@Override
@@ -65,4 +65,8 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
setResult(RESULT_OK, resultIntent);
finish();
}
@Override
public void onSelectionChanged() {
}
}
@@ -45,30 +45,32 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnticipateInterpolator;
import android.view.animation.OvershootInterpolator;
import android.view.animation.ScaleAnimation;
import android.widget.CompoundButton;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextSwitcher;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat;
import androidx.core.view.OneShotPreDrawListener;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ShapeScrim;
import org.thoughtcrime.securesms.components.camera.CameraView;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
import org.thoughtcrime.securesms.permissions.Permissions;
@@ -115,7 +117,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
private final VerifyScanFragment scanFragment = new VerifyScanFragment();
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityDatabase.IdentityRecord identityRecord)
@NonNull IdentityRecord identityRecord)
{
return newIntent(context,
identityRecord.getRecipientId(),
@@ -124,7 +126,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
}
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityDatabase.IdentityRecord identityRecord,
@NonNull IdentityRecord identityRecord,
boolean verified)
{
return newIntent(context,
@@ -208,12 +210,13 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
.execute();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener {
public static class VerifyDisplayFragment extends Fragment {
public static final String RECIPIENT_ID = "recipient_id";
public static final String REMOTE_NUMBER = "remote_number";
@@ -229,23 +232,28 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
private View container;
private View numbersContainer;
private View loading;
private View qrCodeContainer;
private ImageView qrCode;
private ImageView qrVerified;
private TextView tapLabel;
private TextSwitcher tapLabel;
private TextView description;
private View.OnClickListener clickListener;
private SwitchCompat verified;
private Button verifyButton;
private TextView[] codes = new TextView[12];
private boolean animateSuccessOnDraw = false;
private boolean animateFailureOnDraw = false;
private boolean currentVerifiedState = false;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
this.numbersContainer = container.findViewById(R.id.number_table);
this.loading = container.findViewById(R.id.loading);
this.qrCodeContainer = container.findViewById(R.id.qr_code_container);
this.qrCode = container.findViewById(R.id.qr_code);
this.verified = container.findViewById(R.id.verified_switch);
this.verifyButton = container.findViewById(R.id.verify_button);
this.qrVerified = container.findViewById(R.id.qr_verified);
this.description = container.findViewById(R.id.description);
this.tapLabel = container.findViewById(R.id.tap_label);
@@ -262,11 +270,11 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
this.codes[10] = container.findViewById(R.id.code_eleventh);
this.codes[11] = container.findViewById(R.id.code_twelth);
this.qrCode.setOnClickListener(clickListener);
this.qrCodeContainer.setOnClickListener(clickListener);
this.registerForContextMenu(numbersContainer);
this.verified.setChecked(getArguments().getBoolean(VERIFIED_STATE, false));
this.verified.setOnCheckedChangeListener(this);
updateVerifyButton(getArguments().getBoolean(VERIFIED_STATE, false), false);
this.verifyButton.setOnClickListener((button -> updateVerifyButton(!currentVerifiedState, true)));
return container;
}
@@ -326,6 +334,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
@Override
protected void onPostExecute(Fingerprint fingerprint) {
if (getActivity() == null) return;
VerifyDisplayFragment.this.fingerprint = fingerprint;
setFingerprintViews(fingerprint, true);
getActivity().supportInvalidateOptionsMenu();
@@ -479,7 +488,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
}
private void setRecipientText(Recipient recipient) {
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
description.setMovementMethod(LinkMovementMethod.getInstance());
}
@@ -500,9 +509,11 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
if (animate) {
ViewUtil.fadeIn(qrCode, 1000);
ViewUtil.fadeIn(tapLabel, 1000);
ViewUtil.fadeOut(loading, 300, View.GONE);
} else {
qrCode.setVisibility(View.VISIBLE);
tapLabel.setVisibility(View.VISIBLE);
loading.setVisibility(View.GONE);
}
}
@@ -558,6 +569,8 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__successful_match));
animateVerified();
}
@@ -568,6 +581,8 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__failed_to_verify_safety_number));
animateVerified();
}
@@ -575,7 +590,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
scaleAnimation.setInterpolator(new OvershootInterpolator());
scaleAnimation.setInterpolator(new FastOutSlowInInterpolator());
scaleAnimation.setDuration(800);
scaleAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
@@ -593,6 +608,9 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
scaleAnimation.setInterpolator(new AnticipateInterpolator());
scaleAnimation.setDuration(500);
ViewUtil.animateOut(qrVerified, scaleAnimation, View.GONE);
ViewUtil.fadeIn(qrCode, 800);
qrCodeContainer.setEnabled(true);
tapLabel.setText(getString(R.string.verify_display_fragment__tap_to_scan));
}
}, 2000);
}
@@ -601,53 +619,74 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
public void onAnimationRepeat(Animation animation) {}
});
ViewUtil.fadeOut(qrCode, 200, View.INVISIBLE);
ViewUtil.animateIn(qrVerified, scaleAnimation);
qrCodeContainer.setEnabled(false);
}
@Override
public void onCheckedChanged(CompoundButton buttonView, final boolean isChecked) {
final Recipient recipient = this.recipient.get();
final RecipientId recipientId = recipient.getId();
private void updateVerifyButton(boolean verified, boolean update) {
currentVerifiedState = verified;
SignalExecutors.BOUNDED.execute(() -> {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
if (isChecked) {
Log.i(TAG, "Saving identity: " + recipientId);
DatabaseFactory.getIdentityDatabase(getActivity())
.saveIdentity(recipientId,
remoteIdentity,
VerifiedStatus.VERIFIED, false,
System.currentTimeMillis(), true);
} else {
DatabaseFactory.getIdentityDatabase(getActivity())
.setVerified(recipientId,
remoteIdentity,
VerifiedStatus.DEFAULT);
if (verified) {
verifyButton.setText(R.string.verify_display_fragment__clear_verification);
} else {
verifyButton.setText(R.string.verify_display_fragment__mark_as_verified);
}
if (update) {
final RecipientId recipientId = recipient.getId();
SignalExecutors.BOUNDED.execute(() -> {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
if (verified) {
Log.i(TAG, "Saving identity: " + recipientId);
ApplicationDependencies.getIdentityStore()
.saveIdentityWithoutSideEffects(recipientId,
remoteIdentity,
VerifiedStatus.VERIFIED,
false,
System.currentTimeMillis(),
true);
} else {
ApplicationDependencies.getIdentityStore().setVerified(recipientId, remoteIdentity, VerifiedStatus.DEFAULT);
}
ApplicationDependencies.getJobManager()
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
remoteIdentity,
verified ? VerifiedStatus.VERIFIED
: VerifiedStatus.DEFAULT));
StorageSyncHelper.scheduleSyncForDataChange();
IdentityUtil.markIdentityVerified(getActivity(), recipient.get(), verified, false);
}
ApplicationDependencies.getJobManager()
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
remoteIdentity,
isChecked ? VerifiedStatus.VERIFIED
: VerifiedStatus.DEFAULT));
StorageSyncHelper.scheduleSyncForDataChange();
IdentityUtil.markIdentityVerified(getActivity(), recipient, isChecked, false);
}
});
});
}
}
}
public static class VerifyScanFragment extends Fragment {
private View container;
private CameraView cameraView;
private ShapeScrim cameraScrim;
private ImageView cameraMarks;
private ScanningThread scanningThread;
private ScanListener scanListener;
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
this.cameraView = container.findViewById(R.id.scanner);
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
this.cameraView = container.findViewById(R.id.scanner);
this.cameraScrim = container.findViewById(R.id.camera_scrim);
this.cameraMarks = container.findViewById(R.id.camera_marks);
OneShotPreDrawListener.add(cameraScrim, () -> {
int width = cameraScrim.getScrimWidth();
int height = cameraScrim.getScrimHeight();
ViewUtil.updateLayoutParams(cameraMarks, width, height);
});
return container;
}
@@ -17,28 +17,39 @@
package org.thoughtcrime.securesms;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.media.AudioManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Rational;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.ViewModelProviders;
import androidx.window.DisplayFeature;
import androidx.window.FoldingFeature;
import androidx.window.WindowLayoutInfo;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
@@ -49,6 +60,7 @@ import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeN
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -60,17 +72,19 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.List;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
@@ -87,11 +101,14 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
private DeviceOrientationMonitor deviceOrientationMonitor;
private FullscreenHelper fullscreenHelper;
private WebRtcCallView callScreen;
private TooltipPopup videoTooltip;
private WebRtcCallViewModel viewModel;
private boolean enableVideoIfAvailable;
private FullscreenHelper fullscreenHelper;
private WebRtcCallView callScreen;
private TooltipPopup videoTooltip;
private WebRtcCallViewModel viewModel;
private boolean enableVideoIfAvailable;
private androidx.window.WindowManager windowManager;
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
private ThrottledDebouncer requestNewSizesThrottle;
@Override
protected void attachBaseContext(@NonNull Context newBase) {
@@ -99,6 +116,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
super.attachBaseContext(newBase);
}
@SuppressLint("SourceLockedOrientationActivity")
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate()");
@@ -106,6 +124,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
super.onCreate(savedInstanceState);
boolean isLandscapeEnabled = getResources().getConfiguration().smallestScreenWidthDp >= 480;
if (!isLandscapeEnabled) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.webrtc_call_activity);
@@ -114,12 +137,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
initializeResources();
initializeViewModel();
initializeViewModel(isLandscapeEnabled);
processIntent(getIntent());
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
windowManager = new androidx.window.WindowManager(this);
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
windowManager.registerLayoutChangeCallback(SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
}
@Override
@@ -164,6 +194,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
if (!isInPipMode() || isFinishing()) {
EventBus.getDefault().unregister(this);
requestNewSizesThrottle.clear();
}
if (!viewModel.isCallStarting()) {
@@ -177,9 +208,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
protected void onDestroy() {
super.onDestroy();
windowManager.unregisterLayoutChangeCallback(windowLayoutInfoConsumer);
EventBus.getDefault().unregister(this);
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -206,8 +239,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private boolean enterPipModeIfPossible() {
if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
PictureInPictureParams params = new PictureInPictureParams.Builder()
.setAspectRatio(new Rational(9, 16))
.build();
.setAspectRatio(new Rational(9, 16))
.build();
enterPictureInPictureMode(params);
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
@@ -246,53 +279,46 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
}
private void initializeViewModel() {
private void initializeViewModel(boolean isLandscapeEnabled) {
deviceOrientationMonitor = new DeviceOrientationMonitor(this);
getLifecycle().addObserver(deviceOrientationMonitor);
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
viewModel.setIsInPipMode(isInPipMode());
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientation(), (s, o) -> new Pair<>(s, o == PORTRAIT_BOTTOM_EDGE))
.observe(this, p -> callScreen.updateCallParticipants(p.first(), p.second()));
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
viewModel.getOrientationAndLandscapeEnabled(),
(s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
.observe(this, p -> callScreen.updateCallParticipants(p));
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall());
viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange);
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
if (state != null) {
if (state.needsNewRequestSizes()) {
ApplicationDependencies.getSignalCallManager().updateRenderedResolutions();
requestNewSizesThrottle.publish(() -> ApplicationDependencies.getSignalCallManager().updateRenderedResolutions());
}
}
});
viewModel.getOrientation().observe(this, orientation -> {
ApplicationDependencies.getSignalCallManager().orientationChanged(orientation.getDegrees());
switch (orientation) {
case LANDSCAPE_LEFT_EDGE:
callScreen.rotateControls(90);
break;
case LANDSCAPE_RIGHT_EDGE:
callScreen.rotateControls(-90);
break;
case PORTRAIT_BOTTOM_EDGE:
callScreen.rotateControls(0);
}
});
viewModel.getOrientationAndLandscapeEnabled().observe(this, pair -> ApplicationDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
viewModel.getControlsRotation().observe(this, callScreen::rotateControls);
}
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
if (event instanceof WebRtcCallViewModel.Event.StartCall) {
startCall(((WebRtcCallViewModel.Event.StartCall)event).isVideoCall());
startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall());
return;
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
@@ -404,7 +430,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
.onAllGranted(() -> {
callScreen.setRecipient(recipient);
@@ -488,13 +514,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
new AlertDialog.Builder(this)
.setTitle(R.string.RedPhone_number_not_registered)
.setIcon(R.drawable.ic_warning)
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
.setCancelable(true)
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.show();
.setTitle(R.string.RedPhone_number_not_registered)
.setIcon(R.drawable.ic_warning)
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
.setCancelable(true)
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.show();
}
private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) {
@@ -524,6 +550,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
ApplicationDependencies.getSignalCallManager().requestUpdateGroupMembers();
}
public void handleGroupMemberCountChange(int count) {
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize() && FeatureFlags.groupCallRinging();
callScreen.enableRingGroup(canRing);
ApplicationDependencies.getSignalCallManager().setRingGroup(canRing);
}
private void updateSpeakerHint(boolean showSpeakerHint) {
if (showSpeakerHint) {
callScreen.showSpeakerViewHint();
@@ -586,20 +618,34 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
callScreen.setRecipient(event.getRecipient());
switch (event.getState()) {
case CALL_PRE_JOIN: handleCallPreJoin(event); break;
case CALL_CONNECTED: handleCallConnected(event); break;
case NETWORK_FAILURE: handleServerFailure(); break;
case CALL_RINGING: handleCallRinging(); break;
case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
case NO_SUCH_USER: handleNoSuchUser(event); break;
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(); break;
case CALL_OUTGOING: handleOutgoingCall(event); break;
case CALL_BUSY: handleCallBusy(); break;
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
case CALL_PRE_JOIN:
handleCallPreJoin(event); break;
case CALL_CONNECTED:
handleCallConnected(event); break;
case NETWORK_FAILURE:
handleServerFailure(); break;
case CALL_RINGING:
handleCallRinging(); break;
case CALL_DISCONNECTED:
handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
case CALL_ACCEPTED_ELSEWHERE:
handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
case CALL_DECLINED_ELSEWHERE:
handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
case CALL_ONGOING_ELSEWHERE:
handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
case CALL_NEEDS_PERMISSION:
handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
case NO_SUCH_USER:
handleNoSuchUser(event); break;
case RECIPIENT_UNAVAILABLE:
handleRecipientUnavailable(); break;
case CALL_OUTGOING:
handleOutgoingCall(event); break;
case CALL_BUSY:
handleCallBusy(); break;
case UNTRUSTED_IDENTITY:
handleUntrustedIdentity(event); break;
}
boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
@@ -615,6 +661,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
if (event.getGroupState().isNotIdle()) {
callScreen.setStatusFromGroupCallState(event.getGroupState());
callScreen.setRingGroup(event.shouldRingGroup());
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
}
}
}
@@ -729,5 +780,38 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
public void onLocalPictureInPictureClicked() {
viewModel.onLocalPictureInPictureClicked();
}
@Override
public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) {
if (ringingAllowed) {
ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup);
} else {
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show();
}
}
}
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
@Override
public void accept(WindowLayoutInfo windowLayoutInfo) {
Log.d(TAG, "On WindowLayoutInfo accepted: " + windowLayoutInfo.toString());
Optional<DisplayFeature> feature = windowLayoutInfo.getDisplayFeatures().stream().filter(f -> f instanceof FoldingFeature).findFirst();
viewModel.setIsLandscapeEnabled(feature.isPresent());
setRequestedOrientation(feature.isPresent() ? ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
if (feature.isPresent()) {
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
Rect bounds = foldingFeature.getBounds();
if (foldingFeature.getState() == FoldingFeature.State.HALF_OPENED && bounds.top == bounds.bottom) {
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
} else {
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in flat display mode");
viewModel.setFoldableState(WebRtcControls.FoldableState.flat());
}
}
}
}
}
@@ -32,6 +32,7 @@ public class AudioCodec {
private final AudioRecord audioRecord;
private boolean running = true;
private boolean failed = false;
private boolean finished = false;
public AudioCodec() throws IOException {
@@ -76,10 +77,25 @@ public class AudioCodec {
} catch (IOException e) {
Log.w(TAG, e);
} finally {
mediaCodec.stop();
audioRecord.stop();
mediaCodec.release();
try {
mediaCodec.stop();
} catch (IllegalStateException ise) {
Log.w(TAG, "mediaCodec stop failed.", ise);
}
try {
audioRecord.stop();
} catch (IllegalStateException ise) {
Log.w(TAG, "audioRecord stop failed.", ise);
}
try {
mediaCodec.release();
} catch (IllegalStateException ise) {
Log.w(TAG, "mediaCodec release failed. Probably already released.", ise);
}
audioRecord.release();
StreamUtil.close(outputStream);
@@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.avatar
import android.net.Uri
import org.thoughtcrime.securesms.R
/**
* Represents an Avatar which the user can choose, edit, and render into a bitmap via the renderer.
*/
sealed class Avatar(
open val databaseId: DatabaseId
) {
data class Resource(
val resourceId: Int,
val color: Avatars.ColorPair
) : Avatar(DatabaseId.DoNotPersist) {
override fun isSameAs(other: Avatar): Boolean {
return other is Resource && other.resourceId == resourceId
}
}
data class Text(
val text: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Text && other.databaseId == databaseId
}
}
data class Vector(
val key: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Vector && other.key == key
}
}
data class Photo(
val uri: Uri,
val size: Long,
override val databaseId: DatabaseId
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Photo && databaseId == other.databaseId
}
}
open fun withDatabaseId(databaseId: DatabaseId): Avatar {
throw UnsupportedOperationException()
}
abstract fun isSameAs(other: Avatar): Boolean
companion object {
fun getDefaultForSelf(): Resource = Resource(R.drawable.ic_profile_outline_40, Avatars.colors.random())
fun getDefaultForGroup(): Resource = Resource(R.drawable.ic_group_outline_40, Avatars.colors.random())
}
sealed class DatabaseId {
object DoNotPersist : DatabaseId()
object NotSet : DatabaseId()
data class Saved(val id: Long) : DatabaseId()
}
}
@@ -0,0 +1,69 @@
package org.thoughtcrime.securesms.avatar
import android.os.Bundle
import java.lang.IllegalStateException
/**
* Utility class which encapsulates reading and writing Avatar objects to and from Bundles.
*/
object AvatarBundler {
private const val TEXT = "org.thoughtcrime.securesms.avatar.TEXT"
private const val COLOR = "org.thoughtcrime.securesms.avatar.COLOR"
private const val URI = "org.thoughtcrime.securesms.avatar.URI"
private const val KEY = "org.thoughtcrime.securesms.avatar.KEY"
private const val DATABASE_ID = "org.thoughtcrime.securesms.avatar.DATABASE_ID"
private const val SIZE = "org.thoughtcrime.securesms.avatar.SIZE"
fun bundleText(text: Avatar.Text): Bundle = Bundle().apply {
putString(TEXT, text.text)
putString(COLOR, text.color.code)
putDatabaseId(DATABASE_ID, text.databaseId)
}
fun extractText(bundle: Bundle): Avatar.Text = Avatar.Text(
text = requireNotNull(bundle.getString(TEXT)),
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
databaseId = bundle.getDatabaseId()
)
fun bundlePhoto(photo: Avatar.Photo): Bundle = Bundle().apply {
putParcelable(URI, photo.uri)
putLong(SIZE, photo.size)
putDatabaseId(DATABASE_ID, photo.databaseId)
}
fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo(
uri = requireNotNull(bundle.getParcelable(URI)),
size = bundle.getLong(SIZE),
databaseId = bundle.getDatabaseId()
)
fun bundleVector(vector: Avatar.Vector): Bundle = Bundle().apply {
putString(KEY, vector.key)
putString(COLOR, vector.color.code)
putDatabaseId(DATABASE_ID, vector.databaseId)
}
fun extractVector(bundle: Bundle): Avatar.Vector = Avatar.Vector(
key = requireNotNull(bundle.getString(KEY)),
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
databaseId = bundle.getDatabaseId()
)
private fun Bundle.getDatabaseId(): Avatar.DatabaseId {
val id = getLong(DATABASE_ID, -1L)
return if (id == -1L) {
Avatar.DatabaseId.NotSet
} else {
Avatar.DatabaseId.Saved(id)
}
}
private fun Bundle.putDatabaseId(key: String, databaseId: Avatar.DatabaseId) {
if (databaseId is Avatar.DatabaseId.Saved) {
putLong(key, databaseId.id)
}
}
}
@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.avatar
import android.view.View
import android.widget.ImageView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit
/**
* Selectable color item for choosing colors when editing a Text or Vector avatar.
*/
data class AvatarColorItem(
val colors: Avatars.ColorPair,
val selected: Boolean
) {
companion object {
fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
}
}
class Model(val colorItem: AvatarColorItem) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = newItem.colorItem.colors == colorItem.colors
override fun areContentsTheSame(newItem: Model): Boolean = newItem.colorItem == colorItem
}
private class ViewHolder(itemView: View, private val onAvatarColorClickListener: OnAvatarColorClickListener) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = findViewById(R.id.avatar_color_item)
override fun bind(model: Model) {
itemView.setOnClickListener { onAvatarColorClickListener(model.colorItem.colors) }
imageView.background.colorFilter = SimpleColorFilter(model.colorItem.colors.backgroundColor)
imageView.isSelected = model.colorItem.selected
}
}
}
@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.storage.FileStorage
import java.io.InputStream
object AvatarPickerStorage {
private const val DIRECTORY = "avatar_picker"
private const val FILENAME_BASE = "avatar"
@JvmStatic
fun read(context: Context, fileName: String) = FileStorage.read(context, DIRECTORY, fileName)
fun save(context: Context, media: Media): Uri {
val fileName = FileStorage.save(context, PartAuthority.getAttachmentStream(context, media.uri), DIRECTORY, FILENAME_BASE, MediaUtil.getExtension(context, media.uri) ?: "")
return PartAuthority.getAvatarPickerUri(fileName)
}
fun save(context: Context, inputStream: InputStream): Uri {
val fileName = FileStorage.save(context, inputStream, DIRECTORY, FILENAME_BASE, MimeTypeMap.getSingleton().getExtensionFromMimeType(MediaUtil.IMAGE_JPEG) ?: "")
return PartAuthority.getAvatarPickerUri(fileName)
}
@JvmStatic
fun cleanOrphans(context: Context) {
val avatarFiles = FileStorage.getAllFiles(context, DIRECTORY, FILENAME_BASE)
val database = DatabaseFactory.getAvatarPickerDatabase(context)
val photoAvatars = database
.getAllAvatars()
.filterIsInstance<Avatar.Photo>()
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.map { it.name }
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
avatarFiles
.filter { onDiskButNotInDatabase.contains(it.name) }
.forEach { it.delete() }
photoAvatars
.filter { inDatabaseButNotOnDisk.contains(PartAuthority.getAvatarPickerFilename(it.uri)) }
.forEach { database.deleteAvatar(it) }
}
}
@@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.libsignal.util.guava.Optional
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import javax.annotation.meta.Exhaustive
/**
* Renders Avatar objects into Media objects. This can involve creating a Bitmap, depending on the
* type of Avatar passed to `renderAvatar`
*/
object AvatarRenderer {
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
fun getTypeface(context: Context): Typeface {
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
}
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
@Exhaustive
when (avatar) {
is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed)
is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed)
is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered)
is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed)
}
}
@JvmStatic
fun createTextDrawable(
context: Context,
avatar: Avatar.Text,
inverted: Boolean = false,
size: Int = DIMENSIONS,
): Drawable {
return TextAvatarDrawable(context, avatar, inverted, size)
}
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val drawableResourceId = Avatars.getDrawableResource(avatar.key) ?: return@renderInBackground Result.failure(Exception("Drawable resource for key ${avatar.key} does not exist."))
val vector: Drawable = requireNotNull(AppCompatResources.getDrawable(context, drawableResourceId))
vector.setBounds(0, 0, DIMENSIONS, DIMENSIONS)
canvas.drawColor(avatar.color.backgroundColor)
vector.draw(canvas)
Result.success(Unit)
}
}
private fun renderText(context: Context, avatar: Avatar.Text, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val textDrawable = createTextDrawable(context, avatar)
canvas.drawColor(avatar.color.backgroundColor)
textDrawable.draw(canvas)
Result.success(Unit)
}
}
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
val blob = BlobProvider.getInstance()
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
.createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(blob, avatar.size))
}
}
private fun renderResource(context: Context, avatar: Avatar.Resource, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val resource: Drawable = requireNotNull(AppCompatResources.getDrawable(context, avatar.resourceId))
resource.colorFilter = SimpleColorFilter(avatar.color.foregroundColor)
val padding = (DIMENSIONS * 0.2).toInt()
resource.setBounds(0 + padding, 0 + padding, DIMENSIONS - padding, DIMENSIONS - padding)
canvas.drawColor(avatar.color.backgroundColor)
resource.draw(canvas)
Result.success(Unit)
}
}
private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result<Unit>) {
SignalExecutors.BOUNDED.execute {
val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888)
val canvas = Canvas(canvasBitmap)
val drawResult = drawAvatar(canvas)
if (drawResult.isFailure) {
canvasBitmap.recycle()
onRenderFailed(drawResult.exceptionOrNull())
}
val outStream = ByteArrayOutputStream()
val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream)
canvasBitmap.recycle()
if (!compressed) {
onRenderFailed(IOException("Failed to compress bitmap"))
return@execute
}
val bytes = outStream.toByteArray()
val inStream = ByteArrayInputStream(bytes)
val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
}
}
private fun createMedia(uri: Uri, size: Long): Media {
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent())
}
}
@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Paint
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import kotlin.math.abs
import kotlin.math.min
object Avatars {
/**
* Enum class mirroring AvatarColors codes but utilizing foreground colors for text or icon tinting.
*/
enum class ForegroundColor(private val code: String, @ColorInt val colorInt: Int) {
A100("A100", 0xFF3838F5.toInt()),
A110("A110", 0xFF1251D3.toInt()),
A120("A120", 0xFF086DA0.toInt()),
A130("A130", 0xFF067906.toInt()),
A140("A140", 0xFF661AFF.toInt()),
A150("A150", 0xFF9F00F0.toInt()),
A160("A160", 0xFFB8057C.toInt()),
A170("A170", 0xFFBE0404.toInt()),
A180("A180", 0xFF836B01.toInt()),
A190("A190", 0xFF7D6F40.toInt()),
A200("A200", 0xFF4F4F6D.toInt()),
A210("A210", 0xFF5C5C5C.toInt());
fun deserialize(code: String): ForegroundColor {
return values().find { it.code == code } ?: throw IllegalArgumentException()
}
fun serialize(): String = code
}
/**
* Mapping which associates color codes to ColorPair objects containing background and foreground colors.
*/
val colorMap: Map<String, ColorPair> = ForegroundColor.values().map {
ColorPair(AvatarColor.deserialize(it.serialize()), it)
}.associateBy {
it.code
}
val colors: List<ColorPair> = colorMap.values.toList()
val defaultAvatarsForSelf = linkedMapOf(
"avatar_abstract_01" to DefaultAvatar(R.drawable.ic_avatar_abstract_01, "A130"),
"avatar_abstract_02" to DefaultAvatar(R.drawable.ic_avatar_abstract_02, "A120"),
"avatar_abstract_03" to DefaultAvatar(R.drawable.ic_avatar_abstract_03, "A170"),
"avatar_cat" to DefaultAvatar(R.drawable.ic_avatar_cat, "A190"),
"avatar_dog" to DefaultAvatar(R.drawable.ic_avatar_dog, "A140"),
"avatar_fox" to DefaultAvatar(R.drawable.ic_avatar_fox, "A190"),
"avatar_tucan" to DefaultAvatar(R.drawable.ic_avatar_tucan, "A120"),
"avatar_sloth" to DefaultAvatar(R.drawable.ic_avatar_sloth, "A160"),
"avatar_dinosaur" to DefaultAvatar(R.drawable.ic_avatar_dinosour, "A130"),
"avatar_pig" to DefaultAvatar(R.drawable.ic_avatar_pig, "A180"),
"avatar_incognito" to DefaultAvatar(R.drawable.ic_avatar_incognito, "A220"),
"avatar_ghost" to DefaultAvatar(R.drawable.ic_avatar_ghost, "A100")
)
val defaultAvatarsForGroup = linkedMapOf(
"avatar_heart" to DefaultAvatar(R.drawable.ic_avatar_heart, "A180"),
"avatar_house" to DefaultAvatar(R.drawable.ic_avatar_house, "A120"),
"avatar_melon" to DefaultAvatar(R.drawable.ic_avatar_melon, "A110"),
"avatar_drink" to DefaultAvatar(R.drawable.ic_avatar_drink, "A170"),
"avatar_celebration" to DefaultAvatar(R.drawable.ic_avatar_celebration, "A100"),
"avatar_balloon" to DefaultAvatar(R.drawable.ic_avatar_balloon, "A220"),
"avatar_book" to DefaultAvatar(R.drawable.ic_avatar_book, "A100"),
"avatar_briefcase" to DefaultAvatar(R.drawable.ic_avatar_briefcase, "A180"),
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
)
@DrawableRes
fun getDrawableResource(key: String): Int? {
val defaultAvatar = defaultAvatarsForSelf.getOrDefault(key, defaultAvatarsForGroup[key])
return defaultAvatar?.vectorDrawableId
}
private fun textPaint(context: Context) = Paint().apply {
isAntiAlias = true
typeface = AvatarRenderer.getTypeface(context)
textSize = 1f
}
/**
* Calculate the text size for a give string using a maximum desired width and a maximum desired font size.
*/
@JvmStatic
fun getTextSizeForLength(context: Context, text: String, @Px maxWidth: Float, @Px maxSize: Float): Float {
val paint = textPaint(context)
return branchSizes(0f, maxWidth / 2, maxWidth, maxSize, paint, text)
}
/**
* Uses binary search to determine optimal font size to within 1% given the input parameters.
*/
private fun branchSizes(@Px lastFontSize: Float, @Px fontSize: Float, @Px target: Float, @Px maxFontSize: Float, paint: Paint, text: String): Float {
paint.textSize = fontSize
val textWidth = paint.measureText(text)
val delta = abs(lastFontSize - fontSize) / 2f
val isWithinThreshold = abs(1f - (textWidth / target)) <= 0.01f
if (textWidth == 0f) {
return maxFontSize
}
if (delta == 0f) {
return min(maxFontSize, fontSize)
}
return when {
fontSize >= maxFontSize -> {
maxFontSize
}
isWithinThreshold -> {
fontSize
}
textWidth > target -> {
branchSizes(fontSize, fontSize - delta, target, maxFontSize, paint, text)
}
else -> {
branchSizes(fontSize, fontSize + delta, target, maxFontSize, paint, text)
}
}
}
@JvmStatic
fun getForegroundColor(avatarColor: AvatarColor): ForegroundColor {
return ForegroundColor.values().firstOrNull { it.serialize() == avatarColor.serialize() } ?: ForegroundColor.A210
}
data class DefaultAvatar(
@DrawableRes val vectorDrawableId: Int,
val colorCode: String
)
data class ColorPair(
val backgroundAvatarColor: AvatarColor,
val foregroundAvatarColor: ForegroundColor
) {
@ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
@ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
val code: String = backgroundAvatarColor.serialize()
}
}
@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.Gravity
import android.widget.FrameLayout
import androidx.core.view.updateLayoutParams
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
/**
* Uses EmojiTextView to properly render a Text Avatar with emoji in it.
*/
class TextAvatarDrawable(
context: Context,
avatar: Avatar.Text,
inverted: Boolean = false,
private val size: Int = AvatarRenderer.DIMENSIONS,
) : Drawable() {
private val layout: FrameLayout = FrameLayout(context)
private val textView: EmojiTextView = EmojiTextView(context)
init {
textView.typeface = AvatarRenderer.getTypeface(context)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
textView.text = avatar.text
textView.gravity = Gravity.CENTER
textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
textView.setForceCustomEmoji(true)
layout.addView(textView)
textView.updateLayoutParams {
width = size
height = size
}
layout.measure(size, size)
layout.layout(0, 0, size, size)
}
override fun getIntrinsicHeight(): Int = size
override fun getIntrinsicWidth(): Int = size
override fun draw(canvas: Canvas) {
layout.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.OPAQUE
}
@@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.avatar.photo
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.setFragmentResult
import androidx.navigation.Navigation
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri)
childFragmentManager.commit {
add(R.id.fragment_container, imageEditorFragment, IMAGE_EDITOR)
}
}
override fun onTouchEventsNeeded(needed: Boolean) {
}
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) {
}
override fun onDoneEditing() {
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
val applicationContext = requireContext().applicationContext
val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment
SignalExecutors.BOUNDED.execute {
val editedImageUri = imageEditorFragment.renderToSingleUseBlob()
val size = BlobProvider.getFileSize(editedImageUri) ?: 0
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream)
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val database = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val newPhoto = photo.copy(uri = onDiskUri, size = size)
database.update(newPhoto)
BlobProvider.getInstance().delete(requireContext(), photo.uri)
ThreadUtil.runOnMain {
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
Navigation.findNavController(requireView()).popBackStack()
}
}
}
override fun onCancelEditing() {
Navigation.findNavController(requireView()).popBackStack()
}
override fun onMainImageLoaded() {
}
override fun onMainImageFailedToLoad() {
}
companion object {
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"
private const val IMAGE_EDITOR = "image_editor"
}
}
@@ -0,0 +1,242 @@
package org.thoughtcrime.securesms.avatar.picker
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
import org.thoughtcrime.securesms.components.ButtonStripItemView
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
/**
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
*/
class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
companion object {
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
private const val REQUEST_CODE_SELECT_IMAGE = 1
}
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var recycler: RecyclerView
private fun createFactory(): AvatarPickerViewModel.Factory {
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
val groupId = ParcelableGroupId.get(args.groupId)
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar)
val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera)
val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo)
val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text)
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
recycler = view.findViewById(R.id.avatar_picker_recycler)
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
val adapter = MappingAdapter()
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
recycler.adapter = adapter
val avatarViewHolder = AvatarPickerItem.ViewHolder(view)
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.currentAvatar != null) {
avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
}
clearButton.visible = state.canClear
val wasEnabled = saveButton.isEnabled
saveButton.isEnabled = state.canSave
if (wasEnabled != state.canSave) {
val alpha = if (state.canSave) 1f else 0.5f
saveButton.animate().cancel()
saveButton.animate().alpha(alpha)
}
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
val selectedPosition = items.indexOfFirst { it.isSelected }
adapter.submitList(items) {
if (selectedPosition > -1)
recycler.smoothScrollToPosition(selectedPosition)
}
}
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
cameraButton.setOnIconClickedListener { openCameraCapture() }
photoButton.setOnIconClickedListener { openGallery() }
textButton.setOnIconClickedListener { openTextEditor(null) }
saveButton.setOnClickListener { v ->
viewModel.save(
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putParcelable(SELECT_AVATAR_MEDIA, it)
}
)
Navigation.findNavController(v).popBackStack()
},
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putBoolean(SELECT_AVATAR_CLEAR, true)
}
)
Navigation.findNavController(v).popBackStack()
}
)
}
clearButton.setOnClickListener { viewModel.clear() }
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
val text = AvatarBundler.extractText(bundle)
viewModel.onAvatarEditCompleted(text)
}
setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle ->
val vector = AvatarBundler.extractVector(bundle)
viewModel.onAvatarEditCompleted(vector)
}
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
val photo = AvatarBundler.extractPhoto(bundle)
viewModel.onAvatarEditCompleted(photo)
}
}
override fun onResume() {
super.onResume()
ViewUtil.hideKeyboard(requireContext(), requireView())
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
viewModel.onAvatarPhotoSelectionCompleted(media)
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) {
if (isSelected) {
openEditor(avatar)
} else {
viewModel.onAvatarSelectedFromGrid(avatar)
}
}
private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean {
val menuRes = when (avatar) {
is Avatar.Photo -> R.menu.avatar_picker_context
is Avatar.Text -> R.menu.avatar_picker_context
is Avatar.Vector -> return true
is Avatar.Resource -> return true
}
val popup = PopupMenu(context, anchorView, Gravity.TOP)
popup.menuInflater.inflate(menuRes, popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_delete -> viewModel.delete(avatar)
}
true
}
popup.show()
return true
}
fun openEditor(avatar: Avatar) {
when (avatar) {
is Avatar.Photo -> openPhotoEditor(avatar)
is Avatar.Resource -> throw UnsupportedOperationException()
is Avatar.Text -> openTextEditor(avatar)
is Avatar.Vector -> openVectorEditor(avatar)
}
}
private fun openPhotoEditor(photo: Avatar.Photo) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
}
private fun openVectorEditor(vector: Avatar.Vector) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
}
private fun openTextEditor(text: Avatar.Text?) {
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
}
private fun openCameraCapture() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.onAnyDenied {
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT)
.show()
}
.execute()
}
private fun openGallery() {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForGallery(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.onAnyDenied {
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT)
.show()
}
.execute()
}
}
@@ -0,0 +1,147 @@
package org.thoughtcrime.securesms.avatar.picker
import android.util.TypedValue
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.setPadding
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean
object AvatarPickerItem {
private val SELECTION_CHANGED = Any()
fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
}
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar)
override fun areContentsTheSame(newItem: Model): Boolean = avatar == newItem.avatar && isSelected == newItem.isSelected
override fun getChangePayload(newItem: Model): Any? {
return if (newItem.avatar == avatar && isSelected != newItem.isSelected) {
SELECTION_CHANGED
} else {
null
}
}
}
class ViewHolder(
itemView: View,
private val onAvatarClickListener: OnAvatarClickListener? = null,
private val onAvatarLongClickListener: OnAvatarLongClickListener? = null
) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image)
private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text)
private val selectedFader: View? = itemView.findViewById(R.id.avatar_picker_item_fader)
private val selectedOverlay: View? = itemView.findViewById(R.id.avatar_picker_item_selection_overlay)
init {
textView.typeface = AvatarRenderer.getTypeface(context)
textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateFontSize(textView.text.toString())
}
}
private fun updateFontSize(text: String) {
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
if (textView !is EditText) {
textView.text = text
}
}
override fun bind(model: Model) {
val alpha = if (model.isSelected) 1f else 0f
val scale = if (model.isSelected) 0.9f else 1f
imageView.animate().cancel()
textView.animate().cancel()
selectedOverlay?.animate()?.cancel()
selectedFader?.animate()?.cancel()
itemView.setOnLongClickListener {
onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false
}
itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) }
if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) {
imageView.animate().scaleX(scale).scaleY(scale)
textView.animate().scaleX(scale).scaleY(scale)
selectedOverlay?.animate()?.alpha(alpha)
selectedFader?.animate()?.alpha(alpha)
return
}
imageView.scaleX = scale
imageView.scaleY = scale
textView.scaleX = scale
textView.scaleY = scale
selectedFader?.alpha = alpha
selectedOverlay?.alpha = alpha
imageView.clearColorFilter()
imageView.setPadding(0)
when (model.avatar) {
is Avatar.Text -> {
textView.visible = true
updateFontSize(model.avatar.text)
if (textView.text.toString() != model.avatar.text) {
textView.text = model.avatar.text
}
imageView.setImageDrawable(null)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
textView.setTextColor(model.avatar.color.foregroundColor)
}
is Avatar.Vector -> {
textView.visible = false
val drawableId = Avatars.getDrawableResource(model.avatar.key)
if (drawableId == null) {
imageView.setImageDrawable(null)
} else {
imageView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId))
}
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
is Avatar.Photo -> {
textView.visible = false
GlideApp.with(imageView).load(DecryptableStreamUriLoader.DecryptableUri(model.avatar.uri)).into(imageView)
}
is Avatar.Resource -> {
imageView.setPadding((imageView.width * 0.2).toInt())
textView.visible = false
GlideApp.with(imageView).clear(imageView)
imageView.setImageResource(model.avatar.resourceId)
imageView.colorFilter = SimpleColorFilter(model.avatar.color.foregroundColor)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
}
}
}
}
@@ -0,0 +1,189 @@
package org.thoughtcrime.securesms.avatar.picker
import android.content.Context
import android.net.Uri
import android.widget.Toast
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.StreamUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NameUtil
import org.whispersystems.signalservice.api.util.StreamDetails
import java.io.IOException
private val TAG = Log.tag(AvatarPickerRepository::class.java)
class AvatarPickerRepository(context: Context) {
private val applicationContext = context.applicationContext
fun getAvatarForSelf(): Single<Avatar> = Single.fromCallable {
val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext)
if (details != null) {
try {
val bytes = StreamUtil.readFully(details.stream)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
details.length,
Avatar.DatabaseId.DoNotPersist
)
} catch (e: IOException) {
Log.w(TAG, "Failed to read avatar!")
getDefaultAvatarForSelf()
}
} else {
getDefaultAvatarForSelf()
}
}
fun getAvatarForGroup(groupId: GroupId): Single<Avatar> = Single.fromCallable {
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) {
try {
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
Avatar.DatabaseId.DoNotPersist
)
} catch (e: IOException) {
Log.w(TAG, "Failed to read group avatar!")
getDefaultAvatarForGroup(recipient.avatarColor)
}
} else {
getDefaultAvatarForGroup(recipient.avatarColor)
}
}
fun getPersistedAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForSelf()
}
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForGroup(groupId)
}
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
}
}
fun getDefaultAvatarsForGroup(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
}
}
fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) {
SignalExecutors.BOUNDED.execute {
onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media))
}
}
fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
}
}
fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
}
}
fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
persistAvatarForSelf(avatar) {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
} else {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
}
fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
persistAvatarForGroup(avatar, groupId) {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
} else {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
}
fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
fun handleRenderFailure(throwable: Throwable?) {
Log.w(TAG, "Failed to render avatar.", throwable)
ThreadUtil.postToMain {
Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show()
}
}
fun getDefaultAvatarForSelf(): Avatar {
val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext))
return if (initials.isNullOrBlank()) {
Avatar.getDefaultForSelf()
} else {
Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
}
}
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
return getDefaultAvatarForGroup(recipient.avatarColor)
}
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
val colorPair = Avatars.colorMap[color?.serialize()]
val defaultColor = Avatar.getDefaultForGroup()
return if (colorPair != null) {
defaultColor.copy(color = colorPair)
} else {
defaultColor
}
}
fun delete(avatar: Avatar, onDelete: () -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
avatarDatabase.deleteAvatar(avatar)
}
onDelete()
}
}
}
@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.picker
import org.thoughtcrime.securesms.avatar.Avatar
data class AvatarPickerState(
val currentAvatar: Avatar? = null,
val selectableAvatars: List<Avatar> = listOf(),
val canSave: Boolean = false,
val canClear: Boolean = false,
val isCleared: Boolean = false
)
@@ -0,0 +1,198 @@
package org.thoughtcrime.securesms.avatar.picker
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.util.livedata.Store
sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val store = Store(AvatarPickerState())
val state: LiveData<AvatarPickerState> = store.stateLiveData
protected abstract fun getAvatar(): Single<Avatar>
protected abstract fun getDefaultAvatarFromRepository(): Avatar
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit)
protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit)
fun delete(avatar: Avatar) {
repository.delete(avatar) {
refreshAvatar()
refreshSelectableAvatars()
}
}
fun clear() {
store.update {
val avatar = getDefaultAvatarFromRepository()
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
}
}
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
if (store.state.isCleared) {
onCleared()
} else {
val avatar = store.state.currentAvatar ?: throw AssertionError()
persistAndCreateMedia(avatar, onSaved)
}
}
fun onAvatarSelectedFromGrid(avatar: Avatar) {
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
}
fun onAvatarEditCompleted(avatar: Avatar) {
persistAvatar(avatar) { saved ->
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
refreshSelectableAvatars()
}
}
fun onAvatarPhotoSelectionCompleted(media: Media) {
repository.writeMediaToMultiSessionStorage(media) { multiSessionUri ->
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
refreshSelectableAvatars()
}
}
}
protected fun refreshAvatar() {
disposables.add(
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
}
)
}
protected fun refreshSelectableAvatars() {
disposables.add(
Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def ->
val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key }
custom + def.filterNot {
it is Avatar.Vector && customKeys.contains(it.key)
}
}.subscribeOn(Schedulers.io()).subscribe { avatars ->
store.update { it.copy(selectableAvatars = avatars) }
}
)
}
private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
override fun onCleared() {
disposables.dispose()
}
private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) {
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> = repository.getAvatarForSelf()
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForSelf()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForSelf(avatar, onPersisted)
}
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
repository.persistAndCreateMediaForSelf(avatar, onSaved)
}
}
private class GroupAvatarPickerViewModel(
private val groupId: GroupId,
private val repository: AvatarPickerRepository,
groupAvatarMedia: Media?
) : AvatarPickerViewModel(repository) {
private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
repository.getAvatarForGroup(groupId)
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForGroup(avatar, groupId, onPersisted)
}
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved)
}
}
private class NewGroupAvatarPickerViewModel(
private val repository: AvatarPickerRepository,
initialMedia: Media?
) : AvatarPickerViewModel(repository) {
private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
Single.fromCallable { getDefaultAvatarFromRepository() }
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar)
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved)
}
class Factory(
private val repository: AvatarPickerRepository,
private val groupId: GroupId?,
private val isNewGroup: Boolean,
private val groupAvatarMedia: Media?
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
val viewModel = if (groupId == null && !isNewGroup) {
SelfAvatarPickerViewModel(repository)
} else if (groupId == null) {
NewGroupAvatarPickerViewModel(repository, groupAvatarMedia)
} else {
GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia)
}
return requireNotNull(modelClass.cast(viewModel))
}
}
}
@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms.avatar.text
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import com.google.android.material.tabs.TabLayout
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerItem
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
import org.thoughtcrime.securesms.components.ControllableTabLayout
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Fragment to create an avatar based off of a Vector or Text (via a pager)
*/
class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragment) {
private val viewModel: TextAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var textInput: EditText
private lateinit var recycler: RecyclerView
private lateinit var content: ConstraintLayout
private val withRecyclerSet = ConstraintSet()
private val withoutRecyclerSet = ConstraintSet()
private var hasBoundFromViewModel: Boolean = false
private fun createFactory(): TextAvatarCreationViewModel.Factory {
val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments())
val textBundle = args.textAvatar
val text = if (textBundle != null) {
AvatarBundler.extractText(textBundle)
} else {
Avatar.Text("", Avatars.colors.random(), Avatar.DatabaseId.NotSet)
}
return TextAvatarCreationViewModel.Factory(text)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.text_avatar_creation_toolbar)
val tabLayout: ControllableTabLayout = view.findViewById(R.id.text_avatar_creation_tabs)
val doneButton: View = view.findViewById(R.id.text_avatar_creation_done)
val keyboardAwareLayout: KeyboardAwareLinearLayout = view.findViewById(R.id.keyboard_aware_layout)
withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content)
withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content_hidden_recycler)
content = view.findViewById(R.id.content)
recycler = view.findViewById(R.id.text_avatar_creation_recycler)
textInput = view.findViewById(R.id.avatar_picker_item_text)
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
BoldSelectionTabItem.registerListeners(tabLayout)
val onTabSelectedListener = OnTabSelectedListener()
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
onTabSelectedListener.onTabSelected(requireNotNull(tabLayout.getTabAt(tabLayout.selectedTabPosition)))
val adapter = MappingAdapter()
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
AvatarColorItem.registerViewHolder(adapter) {
viewModel.setColor(it)
}
recycler.adapter = adapter
val viewHolder = AvatarPickerItem.ViewHolder(view)
viewModel.state.observe(viewLifecycleOwner) { state ->
EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor)
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
hasBoundFromViewModel = true
}
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
textInput.doAfterTextChanged {
if (it != null && hasBoundFromViewModel) {
viewModel.setText(it.toString())
}
}
doneButton.setOnClickListener { v ->
setFragmentResult(REQUEST_KEY_TEXT, AvatarBundler.bundleText(viewModel.getCurrentAvatar()))
Navigation.findNavController(v).popBackStack()
}
textInput.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_NEXT) {
tabLayout.getTabAt(1)?.select()
true
} else {
false
}
}
keyboardAwareLayout.addOnKeyboardHiddenListener {
if (tabLayout.selectedTabPosition == 1) {
val transition = AutoTransition().setStartDelay(250L)
TransitionManager.endTransitions(content)
withRecyclerSet.applyTo(content)
TransitionManager.beginDelayedTransition(content, transition)
}
}
}
private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
when (tab.position) {
0 -> {
textInput.isEnabled = true
ViewUtil.focusAndShowKeyboard(textInput)
withoutRecyclerSet.applyTo(content)
textInput.setSelection(textInput.length())
}
1 -> {
textInput.isEnabled = false
ViewUtil.hideKeyboard(requireContext(), textInput)
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
}
companion object {
const val REQUEST_KEY_TEXT = "org.thoughtcrime.securesms.avatar.text.TEXT"
}
}
@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.text
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class TextAvatarCreationState(
val currentAvatar: Avatar.Text,
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}
@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.avatar.text
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.util.livedata.Store
class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
private val store = Store(TextAvatarCreationState(initialText))
val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
}
fun setText(text: String) {
store.update {
if (it.currentAvatar.text == text) {
it
} else {
it.copy(currentAvatar = it.currentAvatar.copy(text = text))
}
}
}
fun getCurrentAvatar(): Avatar.Text {
return store.state.currentAvatar
}
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
}
}
}
@@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.avatar.vector
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Fragment to create an avatar based off a default vector.
*/
class VectorAvatarCreationFragment : Fragment(R.layout.vector_avatar_creation_fragment) {
private val viewModel: VectorAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
private fun createFactory(): VectorAvatarCreationViewModel.Factory {
val args = VectorAvatarCreationFragmentArgs.fromBundle(requireArguments())
val vectorBundle = args.vectorAvatar
return VectorAvatarCreationViewModel.Factory(AvatarBundler.extractVector(vectorBundle))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.vector_avatar_creation_toolbar)
val recycler: RecyclerView = view.findViewById(R.id.vector_avatar_creation_recycler)
val doneButton: View = view.findViewById(R.id.vector_avatar_creation_done)
val preview: ImageView = view.findViewById(R.id.vector_avatar_creation_image)
val adapter = MappingAdapter()
recycler.adapter = adapter
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
AvatarColorItem.registerViewHolder(adapter) {
viewModel.setColor(it)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
preview.background.colorFilter = SimpleColorFilter(state.currentAvatar.color.backgroundColor)
preview.setImageResource(requireNotNull(Avatars.getDrawableResource(state.currentAvatar.key)))
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
}
toolbar.setNavigationOnClickListener { Navigation.findNavController(view).popBackStack() }
doneButton.setOnClickListener {
setFragmentResult(REQUEST_KEY_VECTOR, AvatarBundler.bundleVector(viewModel.getCurrentAvatar()))
Navigation.findNavController(it).popBackStack()
}
}
companion object {
const val REQUEST_KEY_VECTOR = "org.thoughtcrime.securesms.avatar.text.VECTOR"
}
}
@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.vector
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class VectorAvatarCreationState(
val currentAvatar: Avatar.Vector,
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}
@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.avatar.vector
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.util.livedata.Store
class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel() {
private val store = Store(VectorAvatarCreationState(initialAvatar))
val state: LiveData<VectorAvatarCreationState> = store.stateLiveData
fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
}
fun getCurrentAvatar() = store.state.currentAvatar
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
}
}
}
@@ -13,7 +13,7 @@ import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
import com.google.protobuf.ByteString;
import net.sqlcipher.database.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.Conversions;
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.database.SessionDatabase;
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -84,7 +85,8 @@ public class FullBackupExporter extends FullBackupBase {
EmojiSearchDatabase.TABLE_NAME,
SenderKeyDatabase.TABLE_NAME,
SenderKeySharedDatabase.TABLE_NAME,
PendingRetryReceiptDatabase.TABLE_NAME
PendingRetryReceiptDatabase.TABLE_NAME,
AvatarPickerDatabase.TABLE_NAME
);
public static void export(@NonNull Context context,
@@ -11,7 +11,7 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.Conversions;
@@ -1,24 +1,24 @@
package org.thoughtcrime.securesms.blocked;
import android.app.AlertDialog;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
import android.widget.ViewSwitcher;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -26,6 +26,8 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.function.Consumer;
public class BlockedUsersActivity extends PassphraseRequiredActivity implements BlockedUsersFragment.Listener, ContactSelectionListFragment.OnContactSelectedListener {
private static final String CONTACT_SELECTION_FRAGMENT = "Contact.Selection.Fragment";
@@ -47,27 +49,26 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
viewModel = ViewModelProviders.of(this, factory).get(BlockedUsersViewModel.class);
ViewSwitcher viewSwitcher = findViewById(R.id.toolbar_switcher);
Toolbar toolbar = findViewById(R.id.toolbar);
ContactFilterToolbar contactFilterToolbar = findViewById(R.id.filter_toolbar);
View container = findViewById(R.id.fragment_container);
Toolbar toolbar = findViewById(R.id.toolbar);
ContactFilterView contactFilterView = findViewById(R.id.contact_filter_edit_text);
View container = findViewById(R.id.fragment_container);
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
contactFilterToolbar.setNavigationOnClickListener(unused -> onBackPressed());
contactFilterToolbar.setOnFilterChangedListener(query -> {
contactFilterView.setOnFilterChangedListener(query -> {
Fragment fragment = getSupportFragmentManager().findFragmentByTag(CONTACT_SELECTION_FRAGMENT);
if (fragment != null) {
((ContactSelectionListFragment) fragment).setQueryFilter(query);
}
});
contactFilterToolbar.setHint(R.string.BlockedUsersActivity__add_blocked_user);
contactFilterView.setHint(R.string.BlockedUsersActivity__add_blocked_user);
//noinspection CodeBlock2Expr
getSupportFragmentManager().addOnBackStackChangedListener(() -> {
viewSwitcher.setDisplayedChild(getSupportFragmentManager().getBackStackEntryCount());
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
contactFilterToolbar.focusAndShowKeyboard();
contactFilterView.setVisibility(View.VISIBLE);
contactFilterView.focusAndShowKeyboard();
} else {
contactFilterView.setVisibility(View.GONE);
}
});
@@ -86,24 +87,24 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number);
AlertDialog confirmationDialog = new AlertDialog.Builder(BlockedUsersActivity.this)
.setTitle(R.string.BlockedUsersActivity__block_user)
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
if (recipientId.isPresent()) {
viewModel.block(recipientId.get());
} else {
viewModel.createAndBlock(number);
}
dialog.dismiss();
onBackPressed();
})
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.setCancelable(true)
.create();
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.BlockedUsersActivity__block_user)
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
if (recipientId.isPresent()) {
viewModel.block(recipientId.get());
} else {
viewModel.createAndBlock(number);
}
dialog.dismiss();
onBackPressed();
})
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.setCancelable(true)
.create();
confirmationDialog.setOnShowListener(dialog -> {
confirmationDialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(Color.RED);
@@ -111,7 +112,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
confirmationDialog.show();
return false;
callback.accept(false);
}
@Override
@@ -119,6 +120,10 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onSelectionChanged() {
}
@Override
public void handleAddUserToBlockedList() {
ContactSelectionListFragment fragment = new ContactSelectionListFragment();
@@ -164,6 +169,6 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
throw new IllegalArgumentException("Unsupported event type " + event);
}
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).show();
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show();
}
}
@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Objects;
@@ -63,7 +64,7 @@ final class BlockedUsersAdapter extends ListAdapter<Recipient, BlockedUsersAdapt
displayName.setText(recipient.getDisplayName(itemView.getContext()));
if (recipient.hasAUserSetDisplayName(itemView.getContext())) {
String identifier = recipient.getE164().or(recipient.getUsername()).orNull();
String identifier = recipient.getE164().transform(PhoneNumberFormatter::prettyPrint).or(recipient.getUsername()).orNull();
if (identifier != null) {
numberOrUsername.setText(identifier);
@@ -346,7 +346,7 @@ public final class AudioView extends FrameLayout {
}
if (duration != null && durationMillis > 0) {
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis);
long remainingSecs = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis));
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
}
@@ -106,7 +106,7 @@ public final class AvatarImageView extends AppCompatImageView {
outlinePaint = ThemeUtil.isDarkTheme(context) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN.colorInt(), inverted);
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN, inverted);
blurred = false;
chatColors = null;
}
@@ -248,7 +248,7 @@ public final class AvatarImageView extends AppCompatImageView {
requestManager.clear(this);
if (fallbackPhotoProvider != null) {
setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
.asDrawable(getContext(), AvatarColor.UNKNOWN.colorInt(), inverted));
.asDrawable(getContext(), AvatarColor.UNKNOWN, inverted));
} else {
setImageDrawable(unknownRecipientDrawable);
}
@@ -285,7 +285,7 @@ public final class AvatarImageView extends AppCompatImageView {
{
Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
.getPhotoForGroup()
.asDrawable(getContext(), color.colorInt());
.asDrawable(getContext(), color);
GlideApp.with(this)
.load(avatarBytes)
@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.tabs.TabLayout
import org.thoughtcrime.securesms.R
import java.util.Objects
/**
* Custom View for Tabs which will render bold text when the view is selected
*/
class BoldSelectionTabItem @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private lateinit var unselectedTextView: TextView
private lateinit var selectedTextView: TextView
override fun onFinishInflate() {
super.onFinishInflate()
unselectedTextView = findViewById(android.R.id.text1)
selectedTextView = findViewById(R.id.text1_bold)
unselectedTextView.doAfterTextChanged {
selectedTextView.text = it
}
}
fun select() {
unselectedTextView.alpha = 0f
selectedTextView.alpha = 1f
}
fun unselect() {
unselectedTextView.alpha = 1f
selectedTextView.alpha = 0f
}
companion object {
@JvmStatic
fun registerListeners(tabLayout: ControllableTabLayout) {
val newTabListener = NewTabListener()
val onTabSelectedListener = OnTabSelectedListener()
(0 until tabLayout.tabCount).mapNotNull { tabLayout.getTabAt(it) }.forEach {
newTabListener.onNewTab(it)
if (it.isSelected) {
onTabSelectedListener.onTabSelected(it)
} else {
onTabSelectedListener.onTabUnselected(it)
}
}
tabLayout.setNewTabListener(newTabListener)
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
}
}
private class NewTabListener : ControllableTabLayout.NewTabListener {
override fun onNewTab(tab: TabLayout.Tab) {
val customView = tab.customView
if (customView == null) {
tab.setCustomView(R.layout.bold_selection_tab_item)
}
}
}
private class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
view.select()
}
override fun onTabUnselected(tab: TabLayout.Tab) {
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
view.unselect()
}
override fun onTabReselected(tab: TabLayout.Tab) = Unit
}
}
@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
class ButtonStripItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val iconView: ImageView
private val labelView: TextView
init {
inflate(context, R.layout.button_strip_item_view, this)
iconView = findViewById(R.id.icon)
labelView = findViewById(R.id.label)
val array = context.obtainStyledAttributes(attrs, R.styleable.ButtonStripItemView)
val icon = array.getDrawable(R.styleable.ButtonStripItemView_bsiv_icon)
val contentDescription = array.getString(R.styleable.ButtonStripItemView_bsiv_icon_contentDescription)
val label = array.getString(R.styleable.ButtonStripItemView_bsiv_label)
iconView.setImageDrawable(icon)
iconView.contentDescription = contentDescription
labelView.text = label
array.recycle()
}
fun setOnIconClickedListener(onIconClickedListener: (() -> Unit)?) {
iconView.setOnClickListener { onIconClickedListener?.invoke() }
}
}
@@ -10,19 +10,20 @@ import android.util.AttributeSet;
import android.view.TouchDelegate;
import android.view.View;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar;
public final class ContactFilterToolbar extends DarkOverflowToolbar {
public final class ContactFilterView extends FrameLayout {
private OnFilterChangedListener listener;
private final EditText searchText;
@@ -32,17 +33,17 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
private final ImageView clearToggle;
private final LinearLayout toggleContainer;
public ContactFilterToolbar(Context context) {
public ContactFilterView(Context context) {
this(context, null);
}
public ContactFilterToolbar(Context context, AttributeSet attrs) {
public ContactFilterView(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.toolbarStyle);
}
public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) {
public ContactFilterView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.contact_filter_toolbar, this);
inflate(context, R.layout.contact_filter_view, this);
this.searchText = findViewById(R.id.search_view);
this.toggle = findViewById(R.id.button_toggle);
@@ -99,11 +100,8 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
}
});
setLogo(null);
setContentInsetStartWithNavigation(0);
expandTapArea(toggleContainer, dialpadToggle);
applyAttributes(searchText, context, attrs, defStyleAttr);
searchText.requestFocus();
}
private void applyAttributes(@NonNull EditText searchText,
@@ -123,6 +121,16 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) {
dialpadToggle.setVisibility(GONE);
}
if (attributes.getBoolean(R.styleable.ContactFilterToolbar_cfv_autoFocus, true)) {
searchText.requestFocus();
}
int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterToolbar_cfv_background, -1);
if (backgroundRes != -1) {
findViewById(R.id.background_holder).setBackgroundResource(backgroundRes);
}
attributes.recycle();
}
@@ -139,6 +147,10 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
this.listener = listener;
}
public void setOnSearchInputFocusChangedListener(@Nullable OnFocusChangeListener listener) {
searchText.setOnFocusChangeListener(listener);
}
public void setHint(@StringRes int hint) {
searchText.setHint(hint);
}
@@ -25,7 +25,6 @@ import com.airbnb.lottie.LottieProperty;
import com.airbnb.lottie.model.KeyPath;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -33,15 +32,18 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public class ConversationItemFooter extends ConstraintLayout {
@@ -63,6 +65,8 @@ public class ConversationItemFooter extends ConstraintLayout {
private final Rect speedToggleHitRect = new Rect();
private final int touchTargetSize = ViewUtil.dpToPx(48);
private long previousMessageId;
public ConversationItemFooter(Context context) {
super(context);
init(null);
@@ -146,7 +150,7 @@ public class ConversationItemFooter extends ConstraintLayout {
}
public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) {
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis);
long remainingSecs = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis));
audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
}
@@ -211,6 +215,10 @@ public class ConversationItemFooter extends ConstraintLayout {
}
}
public TextView getDateView() {
return dateView;
}
private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) {
if (onTouchDelegateChangedListener != null) {
onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate);
@@ -307,7 +315,7 @@ public class ConversationItemFooter extends ConstraintLayout {
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
} else {
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
}
@@ -367,6 +375,19 @@ public class ConversationItemFooter extends ConstraintLayout {
}
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
long newMessageId = buildMessageId(messageRecord);
if (previousMessageId == newMessageId && deliveryStatusView.isPending() && !messageRecord.isPending()) {
if (messageRecord.getRecipient().isGroup()) {
SignalLocalMetrics.GroupMessageSend.onUiUpdated(messageRecord.getId());
} else {
SignalLocalMetrics.IndividualMessageSend.onUiUpdated(messageRecord.getId());
}
}
previousMessageId = newMessageId;
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
deliveryStatusView.setNone();
return;
@@ -400,7 +421,7 @@ public class ConversationItemFooter extends ConstraintLayout {
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
showAudioDurationViews();
if (messageRecord.getViewedReceiptCount() > 0) {
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getRecipient(), Recipient.self()))) {
revealDot.setProgress(1f);
} else {
revealDot.setProgress(0f);
@@ -425,6 +446,10 @@ public class ConversationItemFooter extends ConstraintLayout {
playbackSpeedToggleTextView.setVisibility(View.GONE);
}
private long buildMessageId(@NonNull MessageRecord record) {
return record.isMms() ? -record.getId() : record.getId();
}
public interface OnTouchDelegateChangedListener {
void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView);
}
@@ -1,32 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.EditText;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
/**
* Custom styled search view that we can insert into ActionBar menus
*/
public class DarkSearchView extends androidx.appcompat.widget.SearchView {
public DarkSearchView(@NonNull Context context) {
this(context, null);
}
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.search_view_style_dark);
}
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
EditText searchText = findViewById(androidx.appcompat.R.id.search_src_text);
searchText.setTextColor(ContextCompat.getColor(context, R.color.signal_text_toolbar_subtitle));
}
}
@@ -60,6 +60,10 @@ public class DeliveryStatusView extends FrameLayout {
this.setVisibility(View.GONE);
}
public boolean isPending() {
return pendingIndicator.getVisibility() == View.VISIBLE;
}
public void setPending() {
this.setVisibility(View.VISIBLE);
pendingIndicator.setVisibility(View.VISIBLE);
@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Forces rounded corners on BottomSheet
*/
abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() {
protected open val peekHeightPercentage: Float = 0.5f
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.Widget_Signal_FixedRoundedCorners)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
val shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
.build()
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_dialog))
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (bottomSheet.background !== dialogBackground) {
ViewCompat.setBackground(bottomSheet, dialogBackground)
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})
return dialog
}
}
@@ -93,6 +93,10 @@ public class InputPanel extends LinearLayout
private @Nullable Listener listener;
private boolean emojiVisible;
private boolean hideForGroupState;
private boolean hideForBlockedState;
private boolean hideForSearch;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
public InputPanel(Context context) {
@@ -317,6 +321,21 @@ public class InputPanel extends LinearLayout
}
}
public void setHideForGroupState(boolean hideForGroupState) {
this.hideForGroupState = hideForGroupState;
updateVisibility();
}
public void setHideForBlockedState(boolean hideForBlockedState) {
this.hideForBlockedState = hideForBlockedState;
updateVisibility();
}
public void setHideForSearch(boolean hideForSearch) {
this.hideForSearch = hideForSearch;
updateVisibility();
}
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
@@ -445,14 +464,6 @@ public class InputPanel extends LinearLayout
microphoneRecorderView.unlockAction();
}
public void showGifMovedTooltip() {
TooltipPopup.forTarget(mediaKeyboard)
.setBackgroundTint(ContextCompat.getColor(getContext(), R.color.signal_accent_primary))
.setTextColor(getResources().getColor(R.color.core_white))
.setText(R.string.ConversationActivity__gifs_are_now_here)
.show(TooltipPopup.POSITION_ABOVE);
}
public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) {
if (voiceNoteDraft != null) {
voiceNoteDraftView.setDraft(voiceNoteDraft);
@@ -503,6 +514,14 @@ public class InputPanel extends LinearLayout
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
}
private void updateVisibility() {
if (hideForGroupState || hideForBlockedState || hideForSearch) {
setVisibility(GONE);
} else {
setVisibility(VISIBLE);
}
}
public interface Listener extends VoiceNoteDraftView.Listener {
void onRecorderStarted();
void onRecorderLocked();
@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.components
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.annotation.LayoutRes
import androidx.fragment.app.DialogFragment
import org.thoughtcrime.securesms.R
/**
* Fullscreen Dialog Fragment which will dismiss itself when the keyboard is closed
*/
abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
DialogFragment(contentLayoutId),
KeyboardAwareLinearLayout.OnKeyboardShownListener,
KeyboardAwareLinearLayout.OnKeyboardHiddenListener {
private var hasShown = false
override fun onCreate(savedInstanceState: Bundle?) {
setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
super.onCreate(savedInstanceState)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setDimAmount(0f)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
hasShown = false
val view = super.onCreateView(inflater, container, savedInstanceState)
return if (view is KeyboardAwareLinearLayout) {
view.addOnKeyboardShownListener(this)
view.addOnKeyboardHiddenListener(this)
view
} else {
throw IllegalStateException("Expected parent of view hierarchy to be keyboard aware.")
}
}
override fun onKeyboardShown() {
hasShown = true
}
override fun onKeyboardHidden() {
if (hasShown) {
dismissAllowingStateLoss()
}
}
}
@@ -1,13 +1,17 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.text.InputFilter;
import android.util.AttributeSet;
import android.widget.TextView;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiFilter;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
/**
* Custom styled search view that we can insert into ActionBar menus
@@ -23,5 +27,31 @@ public class SearchView extends androidx.appcompat.widget.SearchView {
public SearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
initEmojiFilter();
}
private void initEmojiFilter() {
if (!isInEditMode() && !SignalStore.settings().isPreferSystemEmoji()) {
TextView searchText = findViewById(androidx.appcompat.R.id.search_src_text);
if (searchText != null) {
searchText.setFilters(appendEmojiFilter(searchText));
}
}
}
private InputFilter[] appendEmojiFilter(@NonNull TextView view) {
InputFilter[] originalFilters = view.getFilters();
InputFilter[] result;
if (originalFilters != null) {
result = new InputFilter[originalFilters.length + 1];
System.arraycopy(originalFilters, 0, result, 1, originalFilters.length);
} else {
result = new InputFilter[1];
}
result[0] = new EmojiFilter(view);
return result;
}
}
@@ -23,9 +23,12 @@ public class ShapeScrim extends View {
private final Paint eraser;
private final ShapeType shape;
private final float radius;
private final int canvasColor;
private Bitmap scrim;
private Canvas scrimCanvas;
private int scrimWidth;
private int scrimHeight;
public ShapeScrim(Context context) {
this(context, null);
@@ -57,13 +60,30 @@ public class ShapeScrim extends View {
this.eraser = new Paint();
this.eraser.setColor(0xFFFFFFFF);
this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
this.canvasColor = Color.parseColor("#55BDBDBD");
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
int shortDimension = Math.min(getWidth(), getHeight());
float drawRadius = shortDimension * radius;
float left = (getMeasuredWidth() / 2 ) - drawRadius;
float top = (getMeasuredHeight() / 2) - drawRadius;
float right = left + (drawRadius * 2);
float bottom = top + (drawRadius * 2);
scrimWidth = (int) (right - left);
scrimHeight = (int) (bottom - top);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight();
int shortDimension = Math.min(getWidth(), getHeight());
float drawRadius = shortDimension * radius;
if (scrimCanvas == null) {
@@ -72,7 +92,7 @@ public class ShapeScrim extends View {
}
scrim.eraseColor(Color.TRANSPARENT);
scrimCanvas.drawColor(Color.parseColor("#55BDBDBD"));
scrimCanvas.drawColor(canvasColor);
if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser);
else drawSquare(scrimCanvas, drawRadius, eraser);
@@ -104,4 +124,12 @@ public class ShapeScrim extends View {
canvas.drawRoundRect(square, 25, 25, eraser);
}
public int getScrimWidth() {
return scrimWidth;
}
public int getScrimHeight() {
return scrimHeight;
}
}
@@ -10,9 +10,10 @@ import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.Emoj
import org.thoughtcrime.securesms.util.InsetItemDecoration
import org.thoughtcrime.securesms.util.ViewUtil
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(7)
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(8)
private val VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(6)
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(6)
private val EMOJI_VERTICAL_INSET: Int = ViewUtil.dpToPx(5)
private val HEADER_VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
/**
* Use super class to add insets to the emojis and use the [onDrawOver] to draw the variation
@@ -41,11 +42,12 @@ class EmojiItemDecoration(private val allowVariations: Boolean, private val vari
private class SetInset : InsetItemDecoration.SetInset() {
override fun setInset(outRect: Rect, view: View, parent: RecyclerView) {
val isFirstHeader = view.javaClass == AppCompatTextView::class.java && getPosition(view, parent) == 0
val isHeader = view.javaClass == AppCompatTextView::class.java
outRect.left = HORIZONTAL_INSET
outRect.right = HORIZONTAL_INSET
outRect.top = if (isFirstHeader) 0 else VERTICAL_INSET
outRect.bottom = VERTICAL_INSET
outRect.top = if (isHeader) HEADER_VERTICAL_INSET else EMOJI_VERTICAL_INSET
outRect.bottom = if (isHeader) 0 else EMOJI_VERTICAL_INSET
}
}
}
@@ -19,11 +19,9 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.emoji.EmojiCategory;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingModelList;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
@@ -31,7 +29,6 @@ import java.util.Optional;
public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
private EmojiPageModel model;
private AdapterFactory adapterFactory;
private LinearLayoutManager layoutManager;
private RecyclerView.OnItemTouchListener scrollDisabler;
@@ -125,45 +122,15 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
}
public void onSelected() {
if (getAdapter() != null && (model == null || model.isDynamic())) {
if (getAdapter() != null) {
getAdapter().notifyDataSetChanged();
}
}
public void setList(@NonNull List<MappingModel<?>> list) {
this.model = null;
public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
EmojiPageViewGridAdapter adapter = adapterFactory.create();
setAdapter(adapter);
adapter.submitList(list);
}
public void setModel(@Nullable EmojiPageModel model) {
this.model = model;
EmojiPageViewGridAdapter adapter = adapterFactory.create();
setAdapter(adapter);
adapter.submitList(getMappingModelList());
}
public void bindSearchableAdapter(@Nullable EmojiPageModel model) {
this.model = model;
EmojiPageViewGridAdapter adapter = adapterFactory.create();
setAdapter(adapter);
adapter.submitList(getMappingModelList());
}
private @NonNull MappingModelList getMappingModelList() {
if (model != null) {
boolean emoticonPage = EmojiCategory.EMOTICONS.getKey().equals(model.getKey());
return model.getDisplayEmoji()
.stream()
.map(e -> emoticonPage ? new EmojiPageViewGridAdapter.EmojiTextModel(model.getKey(), e)
: new EmojiPageViewGridAdapter.EmojiModel(model.getKey(), e))
.collect(MappingModelList.collect());
}
return new MappingModelList();
adapter.submitList(list, commitCallback);
}
@Override
@@ -176,8 +143,9 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
if (layoutManager instanceof GridLayoutManager) {
int viewWidth = w - getPaddingStart() - getPaddingEnd();
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
int spanCount = Math.max(w / idealWidth, 1);
int spanCount = Math.max(viewWidth / idealWidth, 1);
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
}
@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.emoji.EmojiPageCache;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.thoughtcrime.securesms.util.ListenableFutureTask;
import java.util.concurrent.ExecutionException;
@@ -52,7 +53,7 @@ class EmojiProvider {
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) {
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo());
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout);
if (drawable != null) {
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
@@ -65,10 +66,17 @@ class EmojiProvider {
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
return getEmojiDrawable(context, drawInfo);
return getEmojiDrawable(context, drawInfo, null);
}
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo) {
/**
* Gets an EmojiDrawable from the Page Cache
*
* @param context Context object used in reading and writing from disk
* @param drawInfo Information about the emoji being displayed
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
*/
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded) {
if (drawInfo == null) {
return null;
}
@@ -77,19 +85,30 @@ class EmojiProvider {
final EmojiSource source = EmojiSource.getLatest();
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
EmojiPageCache.INSTANCE
.load(context, drawInfo.getPage(), lowMemoryDecodeScale)
.addListener(new FutureTaskListener<Bitmap>() {
@Override
public void onSuccess(Bitmap result) {
ThreadUtil.runOnMain(() -> drawable.setBitmap(result));
}
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
@Override
public void onFailure(ExecutionException exception) {
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
}
});
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
ThreadUtil.runOnMain(() -> drawable.setBitmap(((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap()));
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
((EmojiPageCache.LoadResult.Async) loadResult).getTask().addListener(new FutureTaskListener<Bitmap>() {
@Override
public void onSuccess(Bitmap result) {
ThreadUtil.runOnMain(() -> {
drawable.setBitmap(result);
if (onEmojiLoaded != null) {
onEmojiLoaded.run();
}
});
}
@Override
public void onFailure(ExecutionException exception) {
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
}
});
} else {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
return drawable;
}
@@ -122,10 +141,10 @@ class EmojiProvider {
final int xStart = (index % emojiPerRow) * glyphWidth;
final int yStart = (index / emojiPerRow) * glyphHeight;
this.emojiBounds = new Rect(xStart,
yStart,
xStart + glyphWidth,
yStart + glyphHeight);
this.emojiBounds = new Rect(xStart + 1,
yStart + 1,
xStart + glyphWidth - 1,
yStart + glyphHeight - 1);
}
@Override
@@ -14,29 +14,33 @@ public class EmojiSpan extends AnimatingImageSpan {
private final float SHIFT_FACTOR = 1.5f;
private final int size;
private final FontMetricsInt fm;
private int size;
private FontMetricsInt fontMetrics;
public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
super(drawable, tv);
fm = tv.getPaint().getFontMetricsInt();
size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
fontMetrics = tv.getPaint().getFontMetricsInt();
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
getDrawable().setBounds(0, 0, size, size);
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
if (fm != null && this.fm != null) {
fm.ascent = this.fm.ascent;
fm.descent = this.fm.descent;
fm.top = this.fm.top;
fm.bottom = this.fm.bottom;
fm.leading = this.fm.leading;
return size;
if (fm != null && this.fontMetrics != null) {
fm.ascent = this.fontMetrics.ascent;
fm.descent = this.fontMetrics.descent;
fm.top = this.fontMetrics.top;
fm.bottom = this.fontMetrics.bottom;
fm.leading = this.fontMetrics.leading;
} else {
return super.getSize(paint, text, start, end, fm);
this.fontMetrics = paint.getFontMetricsInt();
this.size = Math.abs(this.fontMetrics.descent) + Math.abs(this.fontMetrics.ascent);
getDrawable().setBounds(0, 0, size, size);
}
return size;
}
@Override
@@ -5,8 +5,11 @@ import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.text.Annotation;
import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextDirectionHeuristic;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
@@ -33,19 +36,23 @@ import java.util.List;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private final boolean forceCustom;
private static final char ELLIPSIS = '…';
private CharSequence previousText;
private BufferType previousBufferType;
private float originalFontSize;
private boolean useSystemEmoji;
private boolean sizeChangeInProgress;
private int maxLength;
private CharSequence overflowText;
private CharSequence previousOverflowText;
private boolean renderMentions;
private boolean forceCustom;
private CharSequence previousText;
private BufferType previousBufferType;
private float originalFontSize;
private boolean useSystemEmoji;
private boolean sizeChangeInProgress;
private int maxLength;
private CharSequence overflowText;
private CharSequence previousOverflowText;
private boolean renderMentions;
private boolean measureLastLine;
private int lastLineWidth = -1;
private TextDirectionHeuristic textDirection;
private boolean isJumbomoji;
private MentionRendererDelegate mentionRendererDelegate;
@@ -61,10 +68,11 @@ public class EmojiTextView extends AppCompatTextView {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
measureLastLine = a.getBoolean(R.styleable.EmojiTextView_measureLastLine, false);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
@@ -74,6 +82,8 @@ public class EmojiTextView extends AppCompatTextView {
if (renderMentions) {
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
}
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
}
@Override
@@ -103,8 +113,10 @@ public class EmojiTextView extends AppCompatTextView {
if (emojis <= 4) scale += 0.25f;
if (emojis <= 2) scale += 0.25f;
isJumbomoji = scale > 1.0f;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
} else if (scaleEmojis) {
isJumbomoji = false;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize);
}
@@ -139,11 +151,50 @@ public class EmojiTextView extends AppCompatTextView {
}
}
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
CharSequence text = getText();
if (getLayout() == null || !measureLastLine || text == null || text.length() == 0) {
lastLineWidth = -1;
} else {
Layout layout = getLayout();
int lines = layout.getLineCount();
int start = layout.getLineStart(lines - 1);
int count = text.length() - start;
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, start, count)) ||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, start, count))) {
lastLineWidth = getMeasuredWidth();
} else {
lastLineWidth = (int) getPaint().measureText(text, start, text.length());
}
}
}
public int getLastLineWidth() {
return lastLineWidth;
}
public boolean isSingleLine() {
return getLayout() != null && getLayout().getLineCount() == 1;
}
public boolean isJumbomoji() {
return isJumbomoji;
}
public void setOverflowText(@Nullable CharSequence overflowText) {
this.overflowText = overflowText;
setText(previousText, BufferType.SPANNABLE);
}
public void setForceCustomEmoji(boolean forceCustom) {
if (this.forceCustom != forceCustom) {
this.forceCustom = forceCustom;
setText(previousText, BufferType.SPANNABLE);
}
}
private void ellipsizeAnyTextForMaxLength() {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@@ -20,6 +21,8 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
import java.util.Objects;
public class MediaKeyboard extends FrameLayout implements InputView {
private static final String TAG = Log.tag(MediaKeyboard.class);
@@ -40,6 +43,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
super(context, attrs);
}
public void setFragmentManager(@NonNull FragmentManager fragmentManager) {
this.fragmentManager = fragmentManager;
}
public void setKeyboardListener(@Nullable MediaKeyboardListener listener) {
this.keyboardListener = listener;
}
@@ -125,13 +132,32 @@ public class MediaKeyboard extends FrameLayout implements InputView {
private void initView() {
if (!isInitialised) {
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
if (fragmentManager == null) {
FragmentActivity activity = resolveActivity(getContext());
fragmentManager = activity.getSupportFragmentManager();
}
keyboardPagerFragment = new KeyboardPagerFragment();
fragmentManager.beginTransaction()
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment)
.commitNowAllowingStateLoss();
keyboardState = State.NORMAL;
latestKeyboardHeight = -1;
isInitialised = true;
fragmentManager = ((FragmentActivity) getContext()).getSupportFragmentManager();
keyboardPagerFragment = (KeyboardPagerFragment) fragmentManager.findFragmentById(R.id.media_keyboard_fragment_container);
}
}
private static FragmentActivity resolveActivity(@Nullable Context context) {
if (context instanceof FragmentActivity) {
return (FragmentActivity) context;
} else if (context instanceof ContextThemeWrapper) {
return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
} else {
throw new IllegalStateException("Could not locate FragmentActivity");
}
}
@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.libsignal.util.guava.Optional
open class SingleLineEmojiTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var bufferType: BufferType? = null
init {
maxLines = 1
}
override fun setText(text: CharSequence?, type: BufferType?) {
bufferType = type
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
} else {
val newContent = if (width == 0) {
text
} else {
TextUtils.ellipsize(text, paint, width.toFloat(), TextUtils.TruncateAt.END, false, null)
}
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
val newText = if (newCandidates == null || newCandidates.size() == 0) {
newContent
} else {
EmojiProvider.emojify(newCandidates, newContent, this)
}
super.setText(newText, type)
}
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
if (width > 0 && oldWidth != width) {
setText(text, bufferType ?: BufferType.NORMAL)
}
}
override fun setMaxLines(maxLines: Int) {
check(maxLines == 1) { "setMaxLines: $maxLines != 1" }
super.setMaxLines(maxLines)
}
}
@@ -9,9 +9,11 @@ import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.SignalSessionLock;
@@ -40,12 +42,12 @@ public class UntrustedSendDialog extends AlertDialog.Builder implements DialogIn
@Override
public void onClick(DialogInterface dialog, int which) {
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
final TextSecureIdentityKeyStore identityStore = ApplicationDependencies.getIdentityStore();
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setApproval(identityRecord.getRecipientId(), true);
identityStore.setApproval(identityRecord.getRecipientId(), true);
}
}
@@ -16,7 +16,7 @@ import androidx.annotation.RequiresApi;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import java.util.List;
@@ -11,7 +11,9 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.util.List;
@@ -39,27 +41,16 @@ public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogI
@Override
public void onClick(DialogInterface dialog, int which) {
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
}
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
ApplicationDependencies.getIdentityStore().setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
}
return null;
}
@Override
protected void onPostExecute(Void result) {
resendListener.onResendMessage();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return null;
}, nothing -> resendListener.onResendMessage());
}
public interface ResendListener {
@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.components.recyclerview
import android.graphics.Rect
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Decoration which will add an equal amount of space between each item in a grid.
*/
open class GridDividerDecoration(
private val spanCount: Int,
@Px private val space: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
return setItemOffsets(parent.getChildAdapterPosition(view), view, outRect)
}
protected fun setItemOffsets(position: Int, view: View, outRect: Rect) {
val column = position % spanCount
val isRtl = ViewUtil.isRtl(view)
val distanceFromEnd = spanCount - 1 - column
val spaceStart = (column / spanCount.toFloat()) * space
val spaceEnd = (distanceFromEnd / spanCount.toFloat()) * space
outRect.setStart(spaceStart.toInt(), isRtl)
outRect.setEnd(spaceEnd.toInt(), isRtl)
outRect.bottom = space
}
private fun Rect.setEnd(end: Int, isRtl: Boolean) {
if (isRtl) {
left = end
} else {
right = end
}
}
private fun Rect.setStart(start: Int, isRtl: Boolean) {
if (isRtl) {
right = start
} else {
left = start
}
}
}
@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.recyclerview
import androidx.recyclerview.widget.RecyclerView
/**
* Allows implementor to trigger an animation when the attached recyclerview is
* scrolled.
*/
abstract class OnScrollAnimationHelper : RecyclerView.OnScrollListener() {
private var lastAnimationState = AnimationState.NONE
protected open val duration: Long = 250L
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val newAnimationState = getAnimationState(recyclerView)
if (newAnimationState == lastAnimationState) {
return
}
if (lastAnimationState == AnimationState.NONE) {
setImmediateState(recyclerView)
return
}
when (newAnimationState) {
AnimationState.NONE -> throw AssertionError()
AnimationState.HIDE -> hide(duration)
AnimationState.SHOW -> show(duration)
}
lastAnimationState = newAnimationState
}
fun setImmediateState(recyclerView: RecyclerView) {
val newAnimationState = getAnimationState(recyclerView)
when (newAnimationState) {
AnimationState.NONE -> throw AssertionError()
AnimationState.HIDE -> hide(0L)
AnimationState.SHOW -> show(0L)
}
lastAnimationState = newAnimationState
}
protected open fun getAnimationState(recyclerView: RecyclerView): AnimationState {
return if (recyclerView.canScrollVertically(-1)) AnimationState.SHOW else AnimationState.HIDE
}
/**
* Fired when the RecyclerView is able to be scrolled up
*/
protected abstract fun show(duration: Long)
/**
* Fired when the RecyclerView is not able to be scrolled up
*/
protected abstract fun hide(duration: Long)
enum class AnimationState {
NONE,
HIDE,
SHOW
}
}
@@ -14,6 +14,11 @@ public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager {
super(context, RecyclerView.VERTICAL, reverseLayout);
}
@Override
public boolean supportsPredictiveItemAnimations() {
return false;
}
public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) {
final LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
@Override
@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.components.recyclerview
import android.view.View
/**
* Animates in and out a given view. This is intended to be used to show and hide a toolbar shadow,
* but makes no restrictions in this manner.
*/
open class ToolbarShadowAnimationHelper(private val toolbarShadow: View) : OnScrollAnimationHelper() {
override fun show(duration: Long) {
toolbarShadow.animate()
.setDuration(duration)
.alpha(1f)
}
override fun hide(duration: Long) {
toolbarShadow.animate()
.setDuration(duration)
.alpha(0f)
}
}
@@ -12,6 +12,8 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
abstract class DSLSettingsFragment(
@StringRes private val titleId: Int = -1,
@@ -66,72 +68,4 @@ abstract class DSLSettingsFragment(
}
}
}
abstract class OnScrollAnimationHelper : RecyclerView.OnScrollListener() {
private var lastAnimationState = AnimationState.NONE
protected open val duration: Long = 250L
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val newAnimationState = getAnimationState(recyclerView)
if (newAnimationState == lastAnimationState) {
return
}
if (lastAnimationState == AnimationState.NONE) {
setImmediateState(recyclerView)
return
}
when (newAnimationState) {
AnimationState.NONE -> throw AssertionError()
AnimationState.HIDE -> hide(duration)
AnimationState.SHOW -> show(duration)
}
lastAnimationState = newAnimationState
}
fun setImmediateState(recyclerView: RecyclerView) {
val newAnimationState = getAnimationState(recyclerView)
when (newAnimationState) {
AnimationState.NONE -> throw AssertionError()
AnimationState.HIDE -> hide(0L)
AnimationState.SHOW -> show(0L)
}
lastAnimationState = newAnimationState
}
protected open fun getAnimationState(recyclerView: RecyclerView): AnimationState {
return if (recyclerView.canScrollVertically(-1)) AnimationState.SHOW else AnimationState.HIDE
}
protected abstract fun show(duration: Long)
protected abstract fun hide(duration: Long)
enum class AnimationState {
NONE,
HIDE,
SHOW
}
}
open class ToolbarShadowAnimationHelper(private val toolbarShadow: View) : OnScrollAnimationHelper() {
override fun show(duration: Long) {
toolbarShadow.animate()
.setDuration(duration)
.alpha(1f)
}
override fun hide(duration: Long) {
toolbarShadow.animate()
.setDuration(duration)
.alpha(0f)
}
}
}
@@ -88,7 +88,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
@JvmStatic
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
return getIntentForStartLocation(context, StartLocation.HOME)
return getIntentForStartLocation(context, StartLocation.HELP)
.putExtra(HelpFragment.START_CATEGORY_INDEX, startCategoryIndex)
}
@@ -43,6 +43,11 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
}
}
override fun onResume() {
super.onResume()
viewModel.refreshState()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel = ViewModelProviders.of(this)[AccountSettingsViewModel::class.java]
@@ -70,7 +75,8 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
switchPref(
title = DSLSettingsText.from(R.string.preferences_app_protection__pin_reminders),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__youll_be_asked_less_frequently),
isChecked = state.pinRemindersEnabled,
isChecked = state.hasPin && state.pinRemindersEnabled,
isEnabled = state.hasPin,
onClick = {
setPinRemindersEnabled(!state.pinRemindersEnabled)
}
@@ -80,6 +86,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin),
isChecked = state.registrationLockEnabled,
isEnabled = state.hasPin,
onClick = {
setRegistrationLockEnabled(!state.registrationLockEnabled)
}
@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.livedata.Store
@@ -21,7 +22,7 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
useAddressBook = SignalStore.settings().isPreferSystemContactPhotos,
useSystemEmoji = SignalStore.settings().isPreferSystemEmoji,
enterKeySends = SignalStore.settings().isEnterKeySends,
chatBackupsEnabled = SignalStore.settings().isBackupEnabled
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
)
)
@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
@@ -73,8 +74,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_values),
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_values_description),
title = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config),
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config_description),
onClick = {
refreshRemoteValues()
}
@@ -82,7 +83,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_display)
sectionHeaderPref(R.string.preferences__internal_misc)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_user_details),
@@ -93,6 +94,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_shake_to_report),
summary = DSLSettingsText.from(R.string.preferences__internal_shake_to_report_description),
isChecked = state.shakeToReport,
onClick = {
viewModel.setShakeToReport(!state.shakeToReport)
}
)
dividerPref()
sectionHeaderPref(R.string.preferences__internal_storage_service)
@@ -255,6 +265,18 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_local_metrics)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_local_metrics),
summary = DSLSettingsText.from(R.string.preferences__internal_click_to_clear_all_local_metrics_state),
onClick = {
clearAllLocalMetricsState()
}
)
dividerPref()
sectionHeaderPref(R.string.preferences__internal_calling)
radioPref(
@@ -354,4 +376,9 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
DatabaseFactory.getSenderKeySharedDatabase(requireContext()).deleteAll()
Toast.makeText(context, "Deleted all sender key shared state.", Toast.LENGTH_SHORT).show()
}
private fun clearAllLocalMetricsState() {
LocalMetricsDatabase.getInstance(ApplicationDependencies.getApplication()).clear()
Toast.makeText(context, "Cleared all local metrics state.", Toast.LENGTH_SHORT).show()
}
}
@@ -4,6 +4,7 @@ import org.thoughtcrime.securesms.emoji.EmojiFiles
data class InternalSettingsState(
val seeMoreUserDetails: Boolean,
val shakeToReport: Boolean,
val gv2doNotCreateGv2Groups: Boolean,
val gv2forceInvites: Boolean,
val gv2ignoreServerChanges: Boolean,
@@ -25,6 +25,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setShakeToReport(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.SHAKE_TO_REPORT, enabled)
refresh()
}
fun setGv2DoNotCreateGv2Groups(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.GV2_DO_NOT_CREATE_GV2, enabled)
refresh()
@@ -86,6 +91,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
private fun getState() = InternalSettingsState(
seeMoreUserDetails = SignalStore.internalValues().recipientDetails(),
shakeToReport = SignalStore.internalValues().shakeToReport(),
gv2doNotCreateGv2Groups = SignalStore.internalValues().gv2DoNotCreateGv2Groups(),
gv2forceInvites = SignalStore.internalValues().gv2ForceInvites(),
gv2ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(),
@@ -38,7 +38,7 @@ class ExpireTimerSettingsRepository(val context: Context) {
} else {
DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipientId, newExpirationTime)
val outgoingMessage = OutgoingExpirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L)
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null)
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null, null)
consumer.invoke(Result.success(newExpirationTime))
}
}
@@ -48,6 +48,6 @@ class ExpireTimerSettingsRepository(val context: Context) {
private fun getThreadId(recipientId: RecipientId): Long {
val threadDatabase: ThreadDatabase = DatabaseFactory.getThreadDatabase(context)
val recipient: Recipient = Recipient.resolved(recipientId)
return threadDatabase.getThreadIdFor(recipient)
return threadDatabase.getOrCreateThreadIdFor(recipient)
}
}
@@ -19,7 +19,7 @@ class ExpireTimerSettingsViewModel(val config: Config, private val repository: E
init {
if (recipientId != null) {
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expireMessages, isForRecipient = true) }
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expiresInSeconds, isForRecipient = true) }
} else {
store.update { it.copy(initialTimer = config.initialValue ?: SignalStore.settings().universalExpireTimer) }
}
@@ -15,6 +15,7 @@ sealed class ConversationSettingsEvent {
val groupId: GroupId,
val selectionWarning: Int,
val selectionLimit: Int,
val isAnnouncementGroup: Boolean,
val groupMembersWithoutSelf: List<RecipientId>
) : ConversationSettingsEvent()
@@ -31,6 +31,8 @@ import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.VerifyIdentityActivity
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
@@ -235,11 +237,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
AvatarPreference.Model(
recipient = state.recipient,
onAvatarClick = { avatar ->
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
AvatarPreviewActivity.createTransitionBundle(this, avatar)
)
if (!state.recipient.isSelf) {
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
AvatarPreviewActivity.createTransitionBundle(this, avatar)
)
}
}
}
)
@@ -313,7 +317,15 @@ class ConversationSettingsFragment : DSLSettingsFragment(
ButtonStripPreference.Model(
state = state.buttonStripState,
onVideoClick = {
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
} else {
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
}
},
onAudioClick = {
CommunicationActions.startVoiceCall(requireActivity(), state.recipient)
@@ -662,6 +674,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
ContactsCursorLoader.DisplayMode.FLAG_PUSH,
addMembersToGroup.selectionWarning,
addMembersToGroup.selectionLimit,
addMembersToGroup.isAnnouncementGroup,
addMembersToGroup.groupMembersWithoutSelf
),
REQUEST_CODE_ADD_MEMBERS_TO_GROUP
@@ -11,13 +11,15 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.GroupProtoUtil
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.libsignal.util.guava.Preconditions
import java.io.IOException
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
@@ -61,13 +64,9 @@ class ConversationSettingsRepository(
SignalExecutors.BOUNDED.execute { consumer(DatabaseFactory.getGroupDatabase(context).activeGroupCount > 0) }
}
fun getIdentity(recipientId: RecipientId, consumer: (IdentityDatabase.IdentityRecord?) -> Unit) {
fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(
DatabaseFactory.getIdentityDatabase(context)
.getIdentity(recipientId)
.orNull()
)
consumer(ApplicationDependencies.getIdentityStore().getIdentityRecord(recipientId).orNull())
}
}
@@ -130,9 +129,9 @@ class ConversationSettingsRepository(
members.addAll(groupRecord.members)
members.addAll(pendingMembers)
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits())
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits(), groupRecord.isAnnouncementGroup)
} else {
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits())
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits(), false)
}
)
}
@@ -140,6 +139,25 @@ class ConversationSettingsRepository(
fun addMembers(groupId: GroupId, selected: List<RecipientId>, consumer: (GroupAddMembersResult) -> Unit) {
SignalExecutors.BOUNDED.execute {
val record: GroupDatabase.GroupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get()
if (record.isAnnouncementGroup) {
val needsResolve = selected
.map { Recipient.resolved(it) }
.filter { it.announcementGroupCapability != Recipient.Capability.SUPPORTED && !it.isSelf }
.map { it.id }
.toSet()
ApplicationDependencies.getJobManager().runSynchronously(RetrieveProfileJob(needsResolve), TimeUnit.SECONDS.toMillis(10))
val updatedWithCapabilities = needsResolve.map { Recipient.resolved(it) }
if (updatedWithCapabilities.any { it.announcementGroupCapability != Recipient.Capability.SUPPORTED }) {
consumer(GroupAddMembersResult.Failure(GroupChangeFailureReason.NOT_ANNOUNCEMENT_CAPABLE))
return@execute
}
}
consumer(
try {
val groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected)
@@ -198,7 +216,14 @@ class ConversationSettingsRepository(
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipientId)
val recipient = Recipient.resolved(recipientId)
if (recipient.hasUuid()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireUuid().toString())
}
if (recipient.hasE164()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireE164())
}
}
}
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components.settings.conversation
import android.database.Cursor
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
@@ -43,7 +43,7 @@ sealed class SpecificSettingsState {
abstract val isLoaded: Boolean
data class RecipientSettingsState(
val identityRecord: IdentityDatabase.IdentityRecord? = null,
val identityRecord: IdentityRecord? = null,
val allGroupsInCommon: List<Recipient> = listOf(),
val groupsInCommon: List<Recipient> = listOf(),
val selfHasGroups: Boolean = false,
@@ -75,7 +75,8 @@ sealed class SpecificSettingsState {
private val groupDescriptionLoaded: Boolean = false,
val groupLinkEnabled: Boolean = false,
val membershipCountDescription: String = "",
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE,
val isAnnouncementGroup: Boolean = false
) : SpecificSettingsState() {
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
@@ -144,7 +144,7 @@ sealed class ConversationSettingsViewModel(
isMuteAvailable = !recipient.isSelf,
isSearchAvailable = true
),
disappearingMessagesLifespan = recipient.expireMessages,
disappearingMessagesLifespan = recipient.expiresInSeconds,
canModifyBlockedState = !recipient.isSelf,
specificSettingsState = state.requireRecipientSettingsState().copy(
contactLinkState = when {
@@ -322,6 +322,14 @@ sealed class ConversationSettingsViewModel(
)
}
store.update(liveGroup.isAnnouncementGroup) { announcementGroup, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
isAnnouncementGroup = announcementGroup
)
)
}
val isMessageRequestAccepted: LiveData<Boolean> = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) }
val descriptionState: LiveData<DescriptionState> = LiveDataUtil.combineLatest(liveGroup.description, isMessageRequestAccepted, ::DescriptionState)
@@ -386,11 +394,13 @@ sealed class ConversationSettingsViewModel(
override fun onAddToGroup() {
repository.getGroupCapacity(groupId) { capacityResult ->
if (capacityResult.getRemainingCapacity() > 0) {
internalEvents.postValue(
ConversationSettingsEvent.AddMembersToGroup(
groupId,
capacityResult.getSelectionWarning(),
capacityResult.getSelectionLimit(),
capacityResult.isAnnouncementGroup,
capacityResult.getMembersWithoutSelf()
)
)
@@ -7,7 +7,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
class GroupCapacityResult(
private val selfId: RecipientId,
private val members: List<RecipientId>,
private val selectionLimits: SelectionLimits
private val selectionLimits: SelectionLimits,
val isAnnouncementGroup: Boolean
) {
fun getMembers(): List<RecipientId?> {
return members
@@ -72,6 +72,20 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
viewModel.setNonAdminCanEditGroupInfo(it == 1)
}
)
if (state.announcementGroupPermissionEnabled) {
radioListPref(
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__send_messages),
isEnabled = state.selfCanEditSettings,
listItems = permissionsOptions,
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_send_messages),
selected = getSelected(!state.announcementGroup),
confirmAction = true,
onSelected = {
viewModel.setAnnouncementGroup(it == 0)
}
)
}
}
}
@@ -42,4 +42,18 @@ class PermissionsSettingsRepository(private val context: Context) {
}
}
}
fun applyAnnouncementGroupChange(groupId: GroupId, isAnnouncementGroup: Boolean, error: GroupChangeErrorCallback) {
SignalExecutors.UNBOUNDED.execute {
try {
GroupManager.applyAnnouncementGroupChange(context, groupId.requireV2(), isAnnouncementGroup)
} catch (e: GroupChangeException) {
Log.w(TAG, e)
error.onError(GroupChangeFailureReason.fromException(e))
} catch (e: IOException) {
Log.w(TAG, e)
error.onError(GroupChangeFailureReason.fromException(e))
}
}
}
}
@@ -3,5 +3,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.permissions
data class PermissionsSettingsState(
val selfCanEditSettings: Boolean = false,
val nonAdminCanAddMembers: Boolean = false,
val nonAdminCanEditGroupInfo: Boolean = false
val nonAdminCanEditGroupInfo: Boolean = false,
val announcementGroupPermissionEnabled: Boolean = false,
val announcementGroup: Boolean = false
)
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.groups.GroupAccessControl
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
@@ -33,6 +34,18 @@ class PermissionsSettingsViewModel(
store.update(liveGroup.attributesAccessControl) { attributesAccessControl, state ->
state.copy(nonAdminCanEditGroupInfo = attributesAccessControl == GroupAccessControl.ALL_MEMBERS)
}
store.update(liveGroup.isAnnouncementGroup) { isAnnouncementGroup, state ->
state.copy(
announcementGroup = isAnnouncementGroup,
announcementGroupPermissionEnabled = state.announcementGroupPermissionEnabled || isAnnouncementGroup
)
}
store.update(liveGroup.groupRecipient) { groupRecipient, state ->
val allHaveCapability = groupRecipient.participants.map { it.announcementGroupCapability }.all { it == Recipient.Capability.SUPPORTED }
state.copy(announcementGroupPermissionEnabled = allHaveCapability || state.announcementGroup)
}
}
fun setNonAdminCanAddMembers(nonAdminCanAddMembers: Boolean) {
@@ -47,6 +60,12 @@ class PermissionsSettingsViewModel(
}
}
fun setAnnouncementGroup(announcementGroup: Boolean) {
repository.applyAnnouncementGroupChange(groupId, announcementGroup) { reason ->
internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason))
}
}
private fun Boolean.asGroupAccessControl(): GroupAccessControl {
return if (this) {
GroupAccessControl.ALL_MEMBERS
@@ -5,9 +5,12 @@ import androidx.core.view.ViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Renders a large avatar (80dp) for a given Recipient.
@@ -34,6 +37,7 @@ object AvatarPreference {
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
ViewCompat.setTransitionName(this, "avatar")
setFallbackPhotoProvider(AvatarPreferenceFallbackPhotoProvider())
}
override fun bind(model: Model) {
@@ -42,4 +46,10 @@ object AvatarPreference {
avatar.setOnClickListener { model.onAvatarClick(avatar) }
}
}
private class AvatarPreferenceFallbackPhotoProvider : Recipient.FallbackPhotoProvider() {
override fun getPhotoForGroup(): FallbackContactPhoto {
return FallbackPhoto(R.drawable.ic_group_outline_40, ViewUtil.dpToPx(8))
}
}
}
@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.R
@@ -13,7 +14,6 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment
class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
@@ -110,7 +110,8 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
summary = DSLSettingsText.from(customSoundSummary),
onClick = {
CustomNotificationsDialogFragment.create(state.recipientId).show(parentFragmentManager, null)
val action = SoundsAndNotificationsSettingsFragmentDirections.actionSoundsAndNotificationsSettingsFragmentToCustomNotificationsSettingsFragment(state.recipientId)
Navigation.findNavController(requireView()).navigate(action)
}
)
}
@@ -0,0 +1,166 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.RingtoneUtil
class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomNotificationsDialogFragment__custom_notifications) {
private val vibrateLabels: Array<String> by lazy {
resources.getStringArray(R.array.recipient_vibrate_entries)
}
private val viewModel: CustomNotificationsSettingsViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var callSoundResultLauncher: ActivityResultLauncher<Intent>
private lateinit var messageSoundResultLauncher: ActivityResultLauncher<Intent>
private fun createFactory(): CustomNotificationsSettingsViewModel.Factory {
val recipientId = CustomNotificationsSettingsFragmentArgs.fromBundle(requireArguments()).recipientId
val repository = CustomNotificationsSettingsRepository(requireContext())
return CustomNotificationsSettingsViewModel.Factory(recipientId, repository)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
messageSoundResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
handleResult(result, viewModel::setMessageSound)
}
callSoundResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
handleResult(result, viewModel::setCallSound)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun handleResult(result: ActivityResult, resultHandler: (Uri?) -> Unit) {
val resultCode = result.resultCode
val data = result.data
if (resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
resultHandler(uri)
}
}
private fun getConfiguration(state: CustomNotificationsSettingsState): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.CustomNotificationsDialogFragment__messages)
if (NotificationChannels.supported()) {
switchPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__use_custom_notifications),
isEnabled = state.isInitialLoadComplete,
isChecked = state.hasCustomNotifications,
onClick = { viewModel.setHasCustomNotifications(!state.hasCustomNotifications) }
)
}
clickPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__notification_sound),
summary = DSLSettingsText.from(getRingtoneSummary(requireContext(), state.messageSound, Settings.System.DEFAULT_NOTIFICATION_URI)),
isEnabled = state.controlsEnabled,
onClick = { requestSound(state.messageSound, false) }
)
if (NotificationChannels.supported()) {
switchPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
isChecked = state.messageVibrateEnabled,
onClick = { viewModel.setMessageVibrate(RecipientDatabase.VibrateState.fromBoolean(!state.messageVibrateEnabled)) }
)
} else {
radioListPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
listItems = vibrateLabels,
selected = state.messageVibrateState.id,
onSelected = {
viewModel.setMessageVibrate(RecipientDatabase.VibrateState.fromId(it))
}
)
}
if (state.showCallingOptions) {
dividerPref()
sectionHeaderPref(R.string.CustomNotificationsDialogFragment__call_settings)
clickPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__ringtone),
summary = DSLSettingsText.from(getRingtoneSummary(requireContext(), state.callSound, Settings.System.DEFAULT_RINGTONE_URI)),
isEnabled = state.controlsEnabled,
onClick = { requestSound(state.callSound, true) }
)
radioListPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
listItems = vibrateLabels,
selected = state.callVibrateState.id,
onSelected = {
viewModel.setCallVibrate(RecipientDatabase.VibrateState.fromId(it))
}
)
}
}
}
private fun getRingtoneSummary(context: Context, ringtone: Uri?, defaultNotificationUri: Uri?): String {
if (ringtone == null || ringtone == defaultNotificationUri) {
return context.getString(R.string.CustomNotificationsDialogFragment__default)
} else if (ringtone.toString().isEmpty()) {
return context.getString(R.string.preferences__silent)
} else {
val tone = RingtoneUtil.getRingtone(requireContext(), ringtone)
if (tone != null) {
return tone.getTitle(context)
}
}
return context.getString(R.string.CustomNotificationsDialogFragment__default)
}
private fun requestSound(current: Uri?, forCalls: Boolean) {
val existing: Uri? = when {
current == null -> getDefaultSound(forCalls)
current.toString().isEmpty() -> null
else -> current
}
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, if (forCalls) RingtoneManager.TYPE_RINGTONE else RingtoneManager.TYPE_NOTIFICATION)
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)
}
if (forCalls) {
callSoundResultLauncher.launch(intent)
} else {
messageSoundResultLauncher.launch(intent)
}
}
private fun getDefaultSound(forCalls: Boolean) = if (forCalls) Settings.System.DEFAULT_RINGTONE_URI else Settings.System.DEFAULT_NOTIFICATION_URI
}
@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor
class CustomNotificationsSettingsRepository(context: Context) {
private val context = context.applicationContext
private val executor = SerialExecutor(SignalExecutors.BOUNDED)
fun initialize(recipientId: RecipientId, onInitializationComplete: () -> Unit) {
executor.execute {
val recipient = Recipient.resolved(recipientId)
val database = DatabaseFactory.getRecipientDatabase(context)
if (NotificationChannels.supported() && recipient.notificationChannel != null) {
database.setMessageRingtone(recipient.id, NotificationChannels.getMessageRingtone(context, recipient))
database.setMessageVibrate(recipient.id, RecipientDatabase.VibrateState.fromBoolean(NotificationChannels.getMessageVibrate(context, recipient)))
NotificationChannels.ensureCustomChannelConsistency(context)
}
onInitializationComplete()
}
}
fun setHasCustomNotifications(recipientId: RecipientId, hasCustomNotifications: Boolean) {
executor.execute {
if (hasCustomNotifications) {
createCustomNotificationChannel(recipientId)
} else {
deleteCustomNotificationChannel(recipientId)
}
}
}
fun setMessageVibrate(recipientId: RecipientId, vibrateState: RecipientDatabase.VibrateState) {
executor.execute {
val recipient: Recipient = Recipient.resolved(recipientId)
DatabaseFactory.getRecipientDatabase(context).setMessageVibrate(recipient.id, vibrateState)
NotificationChannels.updateMessageVibrate(context, recipient, vibrateState)
}
}
fun setCallingVibrate(recipientId: RecipientId, vibrateState: RecipientDatabase.VibrateState) {
executor.execute {
DatabaseFactory.getRecipientDatabase(context).setCallVibrate(recipientId, vibrateState)
}
}
fun setMessageSound(recipientId: RecipientId, sound: Uri?) {
executor.execute {
val recipient: Recipient = Recipient.resolved(recipientId)
val defaultValue = SignalStore.settings().messageNotificationSound
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
DatabaseFactory.getRecipientDatabase(context).setMessageRingtone(recipient.id, newValue)
NotificationChannels.updateMessageRingtone(context, recipient, newValue)
}
}
fun setCallSound(recipientId: RecipientId, sound: Uri?) {
executor.execute {
val defaultValue = SignalStore.settings().callRingtone
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
DatabaseFactory.getRecipientDatabase(context).setCallRingtone(recipientId, newValue)
}
}
@WorkerThread
private fun createCustomNotificationChannel(recipientId: RecipientId) {
val recipient: Recipient = Recipient.resolved(recipientId)
val channelId = NotificationChannels.createChannelFor(context, recipient)
DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.id, channelId)
}
@WorkerThread
private fun deleteCustomNotificationChannel(recipientId: RecipientId) {
val recipient: Recipient = Recipient.resolved(recipientId)
DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.id, null)
NotificationChannels.deleteChannelFor(context, recipient)
}
}

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