Compare commits

..

263 Commits

Author SHA1 Message Date
Cody Henthorne
c1df628079 Bump version to 5.32.12 2022-02-22 12:25:02 -05:00
Cody Henthorne
e72cac7db5 Updated language translations. 2022-02-22 12:14:50 -05:00
Greyson Parrelli
cbfa573d3d Improve logging around profile uploads. 2022-02-22 11:37:29 -05:00
Greyson Parrelli
1b404cef34 Fix crash if you've been unregistered for couple months. 2022-02-22 11:36:23 -05:00
Greyson Parrelli
cb66996407 Bump version to 5.32.11 2022-02-21 09:33:36 -05:00
Greyson Parrelli
96f908b068 Updated language translations. 2022-02-21 09:33:15 -05:00
Greyson Parrelli
472c8a441f Allow late initialization of some PNI keys. 2022-02-21 09:14:12 -05:00
Greyson Parrelli
1f0c56546e Improve robustness of PNI migration job. 2022-02-21 09:14:12 -05:00
Greyson Parrelli
97f8b5988d Refactor LiveRecipient fetch to be more clear. 2022-02-21 09:14:12 -05:00
Greyson Parrelli
19dc90b68b Allow group leave operations on blocked groups.
We should be leaving groups *before* they're blocked, but this helps
some other cases.
2022-02-20 22:56:23 -05:00
Greyson Parrelli
67f0ba8624 Bump version to 5.32.10 2022-02-18 23:28:17 -05:00
Greyson Parrelli
a23c27b54b Updated language translations. 2022-02-18 23:27:59 -05:00
Greyson Parrelli
34dec1aec2 Fix reaction bar positioning for scaled items. 2022-02-18 23:27:51 -05:00
Greyson Parrelli
4f1aa34a46 Address issues with PNI app migration. 2022-02-18 23:03:24 -05:00
Greyson Parrelli
a207bf965a Bump version to 5.32.9 2022-02-18 17:35:00 -05:00
Greyson Parrelli
33457acee2 Updated language translations. 2022-02-18 17:34:45 -05:00
Greyson Parrelli
80622147ab Migrate importance of Background channel from Other channel. 2022-02-18 16:00:11 -05:00
Greyson Parrelli
719f5e28d0 fixup! Do not run prekey jobs if you're not registered. 2022-02-18 15:54:15 -05:00
Greyson Parrelli
c2830163b8 Do not run prekey jobs if you're not registered. 2022-02-18 15:23:06 -05:00
Greyson Parrelli
bec9b3d88c Update reaction bar positioning to sit above short messages. 2022-02-18 12:13:57 -05:00
Rashad Sookram
8e25719b7b Fix layout loop while ellipsizing. 2022-02-18 12:12:45 -05:00
Greyson Parrelli
d80722dba7 Bump version to 5.32.8 2022-02-17 17:16:18 -05:00
Greyson Parrelli
aa0ab2134f Updated language translations. 2022-02-17 17:15:52 -05:00
Greyson Parrelli
7ca2420287 Move from ACI to a generic ServiceId. 2022-02-17 17:09:26 -05:00
Rashad Sookram
9f1deda220 Fix unintended line break with default font scale. 2022-02-17 17:09:26 -05:00
Greyson Parrelli
265283fea5 Do not backup null key-values. 2022-02-17 17:09:26 -05:00
Rashad Sookram
fc847db389 Prevent video from restarting on attachment change.
Fixes #11816
2022-02-17 17:09:26 -05:00
Cody Henthorne
975ec47adf Adjust incoming call audio initialization. 2022-02-17 17:09:26 -05:00
Greyson Parrelli
ecc6a7b95e Improving handling of profile key updates for ourselves. 2022-02-17 17:09:26 -05:00
Cody Henthorne
6f788ee3df Improve GV2 state change processing speed. 2022-02-17 17:09:26 -05:00
Rashad Sookram
5080567ca9 Adjust position of reaction bar. 2022-02-17 17:09:26 -05:00
Greyson Parrelli
dec1902dc7 Add provisioning support for PNP. 2022-02-17 17:09:25 -05:00
Greyson Parrelli
c2ca899a7c Separate session store for PNI. 2022-02-16 14:12:34 -05:00
Greyson Parrelli
e8ad1e8ed1 Support PNI prekeys. 2022-02-16 14:12:34 -05:00
Greyson Parrelli
db534cd376 Migrate identity keys to SignalStore. 2022-02-16 14:12:34 -05:00
Cody Henthorne
9a1b8c9bb2 Log incoming ringer create exception. 2022-02-16 14:12:34 -05:00
Cody Henthorne
9389ee17b6 Use individual notification channels for background connection and call status. 2022-02-16 14:12:34 -05:00
Cody Henthorne
a1bcbe9c86 Fix read more on group description in conversation view. 2022-02-16 14:12:34 -05:00
Rashad Sookram
f2d994c772 Fix message visibility on multi-message long press. 2022-02-16 14:12:34 -05:00
Cody Henthorne
6164152b15 Fix crash when attempting to save octet-stream data to a media directory. 2022-02-16 14:12:34 -05:00
Greyson Parrelli
874067909d Replace Flipper with Spinner. 2022-02-16 14:12:34 -05:00
Alex Hart
4bdea886e3 Mark sub badge redemption failed in DonationReceiptRedemptionJob#onFailure. 2022-02-16 14:12:34 -05:00
Alex Hart
fb1ba5a13e Fragmentize MessageDetails. 2022-02-16 14:12:34 -05:00
Greyson Parrelli
b3f4e0a7fe Move scrubber to proper package. 2022-02-16 14:12:34 -05:00
Greyson Parrelli
4db58a27a1 Add an ipv4 scrubber. 2022-02-16 14:12:34 -05:00
Cody Henthorne
1692caeab7 Fix crash with disappearing messages while viewing message details. 2022-02-16 14:12:34 -05:00
Rashad Sookram
2718dca6ea Fix input panel animation when recording.
Fixes #11975
2022-02-16 14:12:34 -05:00
Greyson Parrelli
03fb266690 Bump version to 5.32.7 2022-02-16 14:11:33 -05:00
Greyson Parrelli
bf4d727a86 Updated language translations. 2022-02-16 14:11:07 -05:00
Greyson Parrelli
47c78e3d8a Disable Valentines Day megaphone. 2022-02-16 14:05:10 -05:00
Greyson Parrelli
382edd7157 Fix crash when searching for stickers in media editor. 2022-02-16 14:05:03 -05:00
Cody Henthorne
e01574c6b4 Fix GV2 state change bug. 2022-02-16 14:01:42 -05:00
Greyson Parrelli
44800cf440 Bump version to 5.32.6 2022-02-14 16:07:00 -05:00
Greyson Parrelli
b71ee8f3bc Updated language translations. 2022-02-14 16:06:37 -05:00
Greyson Parrelli
267897b133 Improve UD handling for fallback REST sends.
Special thanks to @stevie553 for the wonderfully-detailed bug report!

Fixes #11991
2022-02-14 14:58:09 -05:00
Greyson Parrelli
e2aec496c5 Add more tests around mixed direction text. 2022-02-14 14:42:48 -05:00
elena
c9c18b91d7 Fix incorrectly identifying CharSequence as having mixedTextDirection 2022-02-14 14:18:24 -05:00
Greyson Parrelli
9b837d3f02 Bump version to 5.32.5 2022-02-14 12:22:46 -05:00
Greyson Parrelli
5344850893 Updated language translations. 2022-02-14 12:22:24 -05:00
Greyson Parrelli
d2e09607fa Do not run StorageSyncJob if you are missing e164/aci. 2022-02-14 12:02:56 -05:00
Greyson Parrelli
590b4dec12 Properly update last emoji search index download time. 2022-02-14 12:02:18 -05:00
Greyson Parrelli
be211547f2 Disable legacy passwords upon restoring a backup. 2022-02-14 12:01:46 -05:00
Cody Henthorne
7cbf269b2a Fix name wrapping in conversation banner. 2022-02-14 11:44:22 -05:00
Rashad Sookram
99d1671a50 Fix crash when selecting info for invalid message. 2022-02-14 09:36:57 -05:00
Alex Hart
6f5475fc94 Bump version to 5.32.4 2022-02-11 14:57:04 -04:00
Alex Hart
a5954efc62 Updated language translations. 2022-02-11 14:57:04 -04:00
Cody Henthorne
b59fee2f6e Fix visual bug with release note mute with a background. 2022-02-11 14:57:04 -04:00
Alex Hart
e4f4682357 Better logging; add payment setup failure params. 2022-02-11 14:57:04 -04:00
Alex Hart
889e17e4d5 Bump version to 5.32.3 2022-02-11 11:53:54 -04:00
Alex Hart
e86c1515c8 Updated language translations. 2022-02-11 11:53:16 -04:00
Alex Hart
aa6fa45949 Display LongMessage in dialog fragment. 2022-02-11 11:48:58 -04:00
Cody Henthorne
ac3196bbb3 Allow call button labels to hyphenate. 2022-02-11 10:31:52 -05:00
Cody Henthorne
0b47c2ae93 Fix deadlock when retrieve avatars. 2022-02-11 10:15:04 -05:00
Cody Henthorne
84296a3860 Fix various issues with release notes channel. 2022-02-11 10:12:09 -05:00
Alex Hart
90e6dd3d7d Don't trampoline in setOnlyPage. 2022-02-11 11:09:13 -04:00
Alex Hart
b56207d977 Add requireListener and hierarchical error. 2022-02-11 11:08:59 -04:00
Alex Hart
34f3ae38cc Remove IsGooglePayAvailable error and check. 2022-02-11 10:05:53 -04:00
Alex Hart
13a015fa13 Bump version to 5.32.2 2022-02-10 17:30:38 -04:00
Cody Henthorne
233ba03f73 Fix crash when reacting to release note channel messages. 2022-02-10 16:26:38 -05:00
Alex Hart
c547553770 Bump version to 5.32.1 2022-02-10 16:41:12 -04:00
Alex Hart
0a5f852c09 Updated language translations. 2022-02-10 16:40:40 -04:00
Cody Henthorne
ddf59fb45a Add internal settings for testing release channel notes. 2022-02-10 14:51:13 -05:00
Alex Hart
5a6d77bae4 Add better error handling for subscriptions. 2022-02-10 14:26:59 -04:00
Greyson Parrelli
ae0d6b5926 Handle unsealed PlaintextContent messages.
Closes #11885

Co-authored-by: AsamK <asamk@gmx.de>
2022-02-10 11:01:18 -05:00
Greyson Parrelli
9917b5d7b4 Update libsignal-client to 0.12.3 2022-02-10 10:35:22 -05:00
Greyson Parrelli
0558d5f0b3 Clear sender key shared state on archive and prekey message receive.
We need to clear the sender key shared state whenever a registrationId
changes. We don't have good hooks for that on Android, so instead we're
just going to reset on every archive and prekey receive. It's a little
overzealous, but given these are rare events anyway, it shouldn't be a
big deal.
2022-02-10 10:35:22 -05:00
Greyson Parrelli
597cf3f576 Add a megaphone to celebrate Valentine's Day. 2022-02-09 20:35:31 -05:00
Cody Henthorne
65af5f0849 Improve large group membership scrolling. 2022-02-09 19:07:16 -05:00
Greyson Parrelli
cff5df4353 Improve typing experience when changing your profile name. 2022-02-09 17:44:27 -05:00
Greyson Parrelli
855bada9b8 Update our QR code scanning library. 2022-02-09 17:16:30 -05:00
Greyson Parrelli
9802724baa Don't shorten message footers for mixed-direction text. 2022-02-09 16:08:21 -05:00
Cody Henthorne
14db5ce349 Improve profile fetching for large groups. 2022-02-09 16:01:56 -05:00
Cody Henthorne
bb1e6ffae0 Improve GV2 update speed by only requesting a full snapshot when necessary. 2022-02-09 14:52:01 -05:00
clado
210bb23aa4 Add content descriptions for in-call buttons.
Fixes #9774
2022-02-09 14:44:50 -04:00
ricebin
de3a6a85c9 Remove duplicate Objects.equals call. 2022-02-09 14:44:17 -04:00
Greyson Parrelli
7ef41c0169 Inline the voice note recording V2 feature flag. 2022-02-09 11:03:42 -05:00
Greyson Parrelli
d08f1b65d0 Do not cluster messages more than three minutes apart. 2022-02-09 10:39:33 -05:00
Greyson Parrelli
5de05edaa1 Include user-agent and API level in debuglog. 2022-02-09 10:14:18 -05:00
Ehren Kret
b556967240 Remove the 'v' prefix on nightly version names. 2022-02-09 10:07:01 -05:00
Greyson Parrelli
80a2e1e3cc Support syncing dontNotifyIfMuted on GV2Records. 2022-02-09 10:03:31 -05:00
Greyson Parrelli
b91a2e1450 Increase backoff for 5xx errors in KbsMigrations. 2022-02-08 17:46:10 -05:00
Alex Hart
45e406013a Bump version to 5.32.0 2022-02-08 16:49:49 -04:00
Alex Hart
deb53e1751 Updated language translations. 2022-02-08 16:49:49 -04:00
Alex Hart
601eb967de Make pending intent flags explicit. 2022-02-08 16:49:49 -04:00
Rashad Sookram
5c03608c8f Clean to ensure that the tests run. 2022-02-08 16:49:49 -04:00
Greyson Parrelli
0877d6a25e Improve handling of unknown fields in storage service.
Improve handling of unknown fields in storage service.

Found a lovely bug today where unmuting chats on mobile didn't sync to my linked devices. Turns out this was a result of the unknown field merging. 

1. When a proto has unknown fields, we store the entire proto in a column in our database.
2. After building a proto that we want to write remotely, we merge the saved proto with unknown fields into constructed proto. Most of the time this is fine.
3. _However_, if one of the values you're trying to set happens to be the same as the default value for the given data type (e.g. setting a long like mutedUntil = 0), then when the protos merge, it treats that field as unset and can override it with the field from the proto with unknown fields.
4. Because we currently have unknown fields in every GV2 record, we could never unmute a GV2 group :(

This changes the order of things so that unknown fields are the first thing applied in the record builder. I did this by requiring them in the builder constructors. That way start off with the unknown fields and then can manually set whatever you want, and it'll be guaranteed to override it.
2022-02-08 16:49:49 -04:00
Greyson Parrelli
83ee4c0147 Break storage reads into pages of 1000. 2022-02-08 16:49:49 -04:00
Sgn-32
c09c6587b9 Don't call a Signal audio call a Signal video call. 2022-02-08 16:49:49 -04:00
Alexandre Erwin Ittner
6617ecdf39 Allow sending message by pressing Ctrl+Enter on a physical keyboard 2022-02-08 16:49:49 -04:00
Alex Hart
b36b34b1fd Do not display SVGs as selectable images.
Fixes #10922
Fixes #11032
2022-02-08 16:49:49 -04:00
Cody Henthorne
d8e0baa9ee Fix duplicate conversation menu entires. 2022-02-08 16:49:49 -04:00
Alex Hart
3bb4cdf46b Fix crash when opening convo popup. 2022-02-08 09:21:03 -04:00
Sgn-32
2181e34e6a Remove unused interface RedPhoneCallTypes. 2022-02-08 09:21:03 -04:00
Sgn-32
d0ca769351 Remove unused class RedPhoneEvent. 2022-02-08 09:21:03 -04:00
Fumiaki Yoshimatsu
a090b07b1c Receive results to the permission request issued from the fragment.
Fixes #11808
2022-02-08 09:21:03 -04:00
Greyson Parrelli
178f5e80e3 Fix ID remapping issues when getting group membership. 2022-02-08 09:21:03 -04:00
Greyson Parrelli
d7bf4f178f Prevent us from ever having no default transport option. 2022-02-08 09:21:03 -04:00
Greyson Parrelli
dd9632da5b Do not include group updates in message search results. 2022-02-08 09:21:03 -04:00
bim
e235ec4129 Fix bug in name rendering on verify screen.
Fixes #11770
2022-02-08 09:21:03 -04:00
Greyson Parrelli
988728be3e Do not allow SMS and Signal messages to cluster.
Fixes #9214
2022-02-08 09:21:03 -04:00
Jim Gustafson
e2d86067cc Update to RingRTC v2.18.0 2022-02-08 09:21:03 -04:00
Greyson Parrelli
b447f98f45 Update libphonenumber to 8.12.42 2022-02-08 09:21:03 -04:00
Alex Hart
3e7f63af43 Add entries for Frisian. 2022-02-08 09:20:45 -04:00
Alex Hart
fdeed850b0 Bump version to 5.31.5 2022-02-07 16:59:18 -04:00
Alex Hart
5c1d4d289f Updated language translations. 2022-02-07 16:58:34 -04:00
Greyson Parrelli
d19cba049d Fix reaction mms trigger. 2022-02-07 15:47:46 -05:00
Rashad Sookram
19ed3cb9ea Fix message gradient when selected. 2022-02-07 12:00:12 -05:00
Alex Hart
cbb23b3d6c Close out search if new intent does not request it.
Fixes #11946
2022-02-07 12:31:04 -04:00
Cody Henthorne
3c8c04d9e5 Hide change number if unregistered. 2022-02-07 09:29:47 -05:00
Alex Hart
c3b792e4cf Add nullability check for requireContext. 2022-02-07 10:12:03 -04:00
Cody Henthorne
8f6998a8f6 Bump version to 5.31.4 2022-02-04 19:44:18 -05:00
Cody Henthorne
49f66a31ff Updated language translations. 2022-02-04 19:32:02 -05:00
Greyson Parrelli
ec34604ffc Fix bug where GV1 storageIds were excluded from set. 2022-02-04 19:26:14 -05:00
Cody Henthorne
8af7c5043a Fix bug with sending after safety number changes. 2022-02-04 17:33:32 -05:00
Cody Henthorne
de1fbcf696 Tweak release note channel requirements for showing. 2022-02-04 16:53:43 -05:00
Rashad Sookram
c4c43ee958 Show reactions above selected message. 2022-02-04 16:11:36 -05:00
Cody Henthorne
a2bf15d105 Bump version to 5.31.3 2022-02-04 14:38:11 -05:00
Cody Henthorne
393ee545c0 Updated language translations. 2022-02-04 14:35:21 -05:00
Rashad Sookram
959bbdae6c Improve UI for context menu in chat. 2022-02-04 14:25:49 -05:00
Cody Henthorne
9f474fadf4 Fix message sending to self for group call messages. 2022-02-04 14:23:29 -05:00
Cody Henthorne
007e8a9dca Fix crash with sticker availability change. 2022-02-04 14:20:15 -05:00
Greyson Parrelli
b081452bed Prevent possible requireContext() crash when updating link preview. 2022-02-04 09:38:30 -05:00
Cody Henthorne
45668e4048 Bump version to 5.31.2 2022-02-03 17:22:49 -05:00
Cody Henthorne
f0bf0784e4 Updated language translations. 2022-02-03 17:17:08 -05:00
Cody Henthorne
a05776551f Fix sending reactions to note to self. 2022-02-03 17:12:30 -05:00
Rashad Sookram
24a875c73a Improve showing context menu with keyboard open. 2022-02-03 17:06:17 -05:00
Alex Hart
f0414922be Fix a couple issues with fragmentization refactor.
* Fix a crash from detached fragment.
* Fix sticker search sends.
2022-02-03 14:48:52 -04:00
Alex Hart
bfae20941a Add permission result handler to ConversationListFragment. 2022-02-03 14:41:11 -04:00
Cody Henthorne
be47e9e928 Fix NPE when receiving media only MMS. 2022-02-03 08:48:36 -05:00
Cody Henthorne
7d627ee8be Bump version to 5.31.1 2022-02-02 19:47:32 -05:00
Cody Henthorne
95276b0192 Updated language translations. 2022-02-02 19:47:16 -05:00
Cody Henthorne
92978b0e3f Fix permission crash with new networking check on API<23. 2022-02-02 19:40:22 -05:00
Cody Henthorne
7d7db1b60a Bump version to 5.31.0 2022-02-02 16:58:58 -05:00
Cody Henthorne
c5c915d446 Updated language translations. 2022-02-02 16:53:06 -05:00
Cody Henthorne
bf28dfee66 Add test for double encoded html in link preview tags. 2022-02-02 16:50:58 -05:00
Cody Henthorne
f091502949 Use newer APIs for detecting network changes. 2022-02-02 16:50:58 -05:00
Anurag Pathak
9b0dec7ece Fix HTML unescaped encoded entities in link preview issue. 2022-02-02 16:50:58 -05:00
Cody Henthorne
d690a52fd7 Add additional timing logs for getting resumable upload spec. 2022-02-02 16:50:58 -05:00
Fumiaki Yoshimatsu
cf0d54d04f Fix the profile image on the toolbar may get clamped in RTL layout in some Android versions.
The bug was [reported in the Beta forum by Xashyar](https://community.signalusers.org/t/beta-feedback-for-the-upcoming-android-5-26-release/38629/36).
2022-02-02 16:50:58 -05:00
Björn Spindel
39169784b0 Match desktop and iOS and order stickers by id. 2022-02-02 16:50:58 -05:00
Cody Henthorne
8348badcd6 Periodically fetch release notes. 2022-02-02 16:50:58 -05:00
Ducros Alix
9114dc83d7 Add missing character to Greek regex. 2022-02-02 16:50:58 -05:00
Alex Hart
87608c6d3a Do not change convo status bar color on API<23 2022-02-02 16:50:58 -05:00
Angus Turnbull
5acbe260e9 Replace GMS utility function.
Fixes #11392
2022-02-02 16:50:58 -05:00
Cody Henthorne
5e31eb5565 Fix re-pin out of order bug.
Fixes #11927
2022-02-02 16:50:58 -05:00
Cody Henthorne
7a241e5fb5 Use group state paging always. 2022-02-02 16:50:58 -05:00
Cody Henthorne
7e299157ec Fix crash when entering chats on devices with odd security enforcement. 2022-02-02 16:50:58 -05:00
Cody Henthorne
1b1001b0e9 Add UI components for Release Channel. 2022-02-02 16:50:58 -05:00
Rashad Sookram
45a91e0896 Update context menu with tweaks from design. 2022-02-01 13:41:31 -05:00
Fumiaki Yoshimatsu
91c7e0a0ee Exclude the recycler view from the transition because the transition could interfere with the recycler recycling an item view that is included in the transition. The app crashes when it happens.
Fixes #11722
2022-02-01 13:41:31 -05:00
Fumiaki Yoshimatsu
1a1213d043 Listen to a broadcast until a shortcut was actually created before popping up a toast.
Fixes #10743
2022-02-01 13:41:31 -05:00
Fumiaki Yoshimatsu
b5f6513917 Colorizer view should be right behind the chat bubbles.
Fixes #11391
2022-02-01 13:41:31 -05:00
Sgn-32
befb720eda Do not show Buttons for Message, Video, Audio/Call in RecipientBottomSheetDialog when recipient is blocked. 2022-02-01 13:41:31 -05:00
Sgn-32
9569b6ab4a Enable hyphenation on notification profiles empty title. 2022-02-01 13:41:31 -05:00
Sgn-32
94078f8b91 Do not show double emoji reaction at end of reaction notification text.
Fixes #11860
2022-02-01 13:41:31 -05:00
Sgn-32
537a1fa2ea Hide add to group in bottom sheet of blocked recipient. 2022-02-01 13:41:31 -05:00
Umangjeet S Pahwa
d6acd5ef36 Hide soft keyboard on welcome screen launch. 2022-02-01 13:41:31 -05:00
Fumiaki Yoshimatsu
08d9aa0947 Decorate item after header/footer with timestamp.
Fixes #11536
2022-02-01 13:41:31 -05:00
Shivansh Goel
355a498b9b Fixed typing indicator showing as unread message. 2022-02-01 13:41:31 -05:00
Rashad Sookram
e4d43ade93 Use context menu when selecting a message in chat. 2022-02-01 13:41:31 -05:00
Fumiaki Yoshimatsu
d254d24d77 Use the last part of the URI if the scheme is "file" to avoid returning null as the file's name. Fixes #8561 2022-02-01 13:41:31 -05:00
Ehren Kret
da34f9e989 Add protoc-3.18.0-osx-x86_64.exe depedency metadata 2022-02-01 13:41:31 -05:00
Cody Henthorne
5de9653149 Force bouncycastle version to 1.70 2022-02-01 13:41:31 -05:00
Alex Hart
4de8807297 Add onBackPressed callback for ConversationParentFragment. 2022-02-01 13:41:31 -05:00
Alex Hart
ccc08e651c Fix vertical translation of header on API23 devices. 2022-02-01 13:41:31 -05:00
Alex Hart
fd86dd3424 Add ViewModel File Template. 2022-02-01 13:41:31 -05:00
Alex Hart
89271ecce2 Remove context leak in LinkPreviewViewModel. 2022-02-01 13:41:31 -05:00
Ehren Kret
af3a39d64e Add new CA certificate to Android trust store for chat server.
Existing one expires in 2023.
2022-02-01 13:41:31 -05:00
Rashad Sookram
125ff83bac Fix l10n when searching for "Note to Self". 2022-02-01 13:41:31 -05:00
Greyson Parrelli
33f4bb0000 Add the ability to have separate ACI and PNI protocol stores. 2022-02-01 13:41:31 -05:00
Cody Henthorne
dd7a2834bc Bump version to 5.30.5 2022-02-01 12:20:37 -05:00
Cody Henthorne
b0bf077797 Updated language translations. 2022-02-01 12:16:19 -05:00
Alex Hart
ef9f1e9884 Fix crash when entering conversation search through bottom sheet. 2022-02-01 10:04:53 -04:00
Cody Henthorne
5423ed1d91 Bump version to 5.30.4 2022-01-31 14:57:48 -05:00
Cody Henthorne
28c446aa2e Updated language translations. 2022-01-31 14:45:08 -05:00
Cody Henthorne
d0042b1f7d Rotate change number feature flag. 2022-01-31 14:34:42 -05:00
Alex Hart
62933ba887 Bump version to 5.30.3 2022-01-28 15:43:46 -04:00
Alex Hart
92884fb3bf Updated language translations. 2022-01-28 15:43:18 -04:00
Cody Henthorne
ee831b0221 Fix PNI collision crash. 2022-01-28 12:16:30 -05:00
Alex Hart
e96ff92029 Bump version to 5.30.2 2022-01-26 16:59:41 -04:00
Alex Hart
ade72b9911 Updated language translations. 2022-01-26 16:59:07 -04:00
Cody Henthorne
053b19846b Add additional log around change number and set PNI. 2022-01-26 15:13:52 -05:00
Alex Hart
8e5500826c Bump version to 5.30.1 2022-01-25 16:27:31 -04:00
Alex Hart
2f0a528c0f Updated language translations. 2022-01-25 16:26:41 -04:00
Greyson Parrelli
840e47a2de Update megaphone priority order. 2022-01-25 16:23:47 -04:00
Alex Hart
79a4ceedf9 Fix issue with result callback from gif search. 2022-01-25 16:23:47 -04:00
Greyson Parrelli
3daa894988 Wait for 7 days before showing the donate megaphone. 2022-01-25 12:22:41 -05:00
Greyson Parrelli
1d14a90ac3 Create local messages for group join request/cancel events. 2022-01-25 11:48:29 -05:00
Alex Hart
e273f914b6 Remove clear-cache call from database migration. 2022-01-25 10:42:41 -04:00
Alex Hart
96844f046f Fix bug where media would not send after being selected in gallery. 2022-01-25 10:02:27 -04:00
Alex Hart
926f5b3cdf Fix page restoration when re-opening media keyboard. 2022-01-25 10:02:27 -04:00
Greyson Parrelli
a0031298d8 Disallow visually-empty profile names. 2022-01-25 10:02:27 -04:00
AsamK
523e21f3be Improve handling of group send errors over websocket.
- Correctly parse error responses from send group message via websocket.
- Reduce logging output for mismatched/stale devices exceptions.
- Only fallback from websocket to socket if there were technical errors.

Closes #11918
2022-01-25 10:02:26 -04:00
Cody Henthorne
15254ee720 Fix crash when registering with an existing recipient with the same PNI. 2022-01-25 10:02:26 -04:00
Greyson Parrelli
8648c74221 Ignore irrelevant P2P updates for inactive groups. 2022-01-25 10:02:26 -04:00
Rashad Sookram
c0ed6b1d41 Fix quote width in outgoing messages with audio.
Fixes #11911
2022-01-25 10:02:26 -04:00
Ehren Kret
1641d501c9 Enable GitHub stale bot for Android repo 2022-01-24 18:01:08 -06:00
Alex Hart
2dd887cd17 Bump version to 5.30.0 2022-01-24 16:01:33 -04:00
Alex Hart
373fa1faec Updated language translations. 2022-01-24 16:00:19 -04:00
Greyson Parrelli
35c5a8106d Migrate to the new KBS and CDS enclaves. 2022-01-24 14:46:51 -05:00
Cody Henthorne
642d37edb2 Prevent updates to blocked groups. 2022-01-24 14:21:21 -05:00
Rashad Sookram
35d0f1fc8c Revert "Fix dynamic language override for app context."
This reverts commit 75a19ada23f4fc0d5111fb74d234c7c6f48ba503.
2022-01-24 12:05:29 -05:00
Greyson Parrelli
78acc485fc Do not send group updates for group join requests/cancelations. 2022-01-24 12:05:29 -05:00
Greyson Parrelli
6e71514209 Fix refresh issues when creating group with first-time contacts. 2022-01-24 12:05:29 -05:00
Cody Henthorne
22c396067d Add storage sync support for linked devices. 2022-01-24 12:05:29 -05:00
Jim Gustafson
4f03c98f60 Update to RingRTC v2.17.0 2022-01-24 12:05:29 -05:00
Cody Henthorne
95cb80a93a Enable Change Number. 2022-01-24 12:05:28 -05:00
Greyson Parrelli
14886ce28e Do not send read receipts for all messages after unblocking. 2022-01-24 12:04:48 -05:00
Alex Hart
a641020ec0 Fix emoji search fragment crash. 2022-01-24 12:04:48 -05:00
Alex Hart
9f622bd689 Create shortcut fallback instead of crashing application. 2022-01-24 12:04:48 -05:00
Alex Hart
6919e352d6 Fix crash when opening reactions bottom sheet. 2022-01-24 12:04:48 -05:00
Rashad Sookram
fd6a2c6b10 Fix dynamic language override for app context.
Fixes #11889
2022-01-24 12:04:48 -05:00
Alex Hart
ab34a9b027 Fix crash after swipe to reply. 2022-01-24 12:04:48 -05:00
Cody Henthorne
08db07e960 Fix ignore content bug with stickers. 2022-01-24 12:04:48 -05:00
Cody Henthorne
b2038e4ca0 Fix crash in notification settings. 2022-01-24 12:04:48 -05:00
Cody Henthorne
c48ea68e7e Keep screen on during video playback. 2022-01-24 12:04:48 -05:00
Cody Henthorne
c548816daa Add contact and key sync message receive support. 2022-01-24 12:04:48 -05:00
Greyson Parrelli
c5028720e3 Remove mock and study build variants.
No longer used.
2022-01-24 12:04:48 -05:00
Greyson Parrelli
35f9437413 Delay database notifications until after a transaction has finished. 2022-01-24 12:04:48 -05:00
Alex Hart
b2b51e63be Wrap ConversationActivity code in a Fragment. 2022-01-24 12:04:48 -05:00
Greyson Parrelli
afd6af6f57 Update change phone number string. 2022-01-24 12:04:48 -05:00
Greyson Parrelli
9ba5660f5b Refactor recipient merging. 2022-01-24 12:04:48 -05:00
Rashad Sookram
8aefd59eaa Update ktlint-gradle. 2022-01-24 12:04:48 -05:00
Cody Henthorne
7203228626 Add partial support for operating as a linked device. 2022-01-24 12:04:48 -05:00
Greyson Parrelli
112f4bb281 Disable minification on debug builds. 2022-01-24 12:04:48 -05:00
Greyson Parrelli
c7fb0e2ab8 Bump version to 5.29.6 2022-01-24 11:23:34 -05:00
Greyson Parrelli
f6cd7b1f3c Updated language translations. 2022-01-24 11:23:19 -05:00
Greyson Parrelli
d40254aa69 Download latest emoji. 2022-01-24 11:17:15 -05:00
Greyson Parrelli
75a13aa22a Bump version to 5.29.5 2022-01-21 12:56:54 -05:00
Greyson Parrelli
5a884d8fc8 Updated language translations. 2022-01-21 12:56:30 -05:00
Greyson Parrelli
b5dcf8e8f1 Improve handling of inbound UD messages. 2022-01-21 12:51:22 -05:00
Cody Henthorne
bfdedd57d1 Update jumbomoji processing and downloading. 2022-01-21 10:31:43 -05:00
Cody Henthorne
2b021f5237 Fix camera icon asset in permission dialog. 2022-01-20 21:06:06 -05:00
Cody Henthorne
791c1ee8dd Fix group send with sender key failure due to mistmatch identity. 2022-01-20 20:41:06 -05:00
Greyson Parrelli
c2f953b097 Ensure reactions are deleted for 'delete for everyone'. 2022-01-19 11:12:14 -05:00
Greyson Parrelli
4984cc8eb4 Bump version to 5.29.4 2022-01-18 17:18:11 -05:00
Greyson Parrelli
23e4856c5e Updated language translations. 2022-01-18 17:17:42 -05:00
Greyson Parrelli
e50787ae20 Trim abandoned reactions from backups.
When you create a backup (or do a device transfer), we skip messages
with expiration timers. However, we still (unintentionally) include the
reactions for those messages in the backup.

These 'abandoned' reactions were being associated with newly-sent
messages because the new messages had the same ID's as the expiring
messages we skipped in the backup.

It's worth noting that in order to hit this bug, you have to:
- Have messages that are expiring, but have not expired yet
- Those messages have to have reactions
- Those message have to be the most recent messages in your message table

Fixes #11327
2022-01-18 17:08:53 -05:00
Cody Henthorne
64e4bcf46a Fix error reporting for failed group sends. 2022-01-18 16:08:13 -05:00
Cody Henthorne
693a82f133 Fix crash blocking a group after leaving it. 2022-01-18 12:17:25 -05:00
Brian Moyer
79d73c9e74 Fix WiFi SMS setting.
Fixes #11898
2022-01-18 10:43:38 -05:00
Rashad Sookram
5a51544cae Fix infinite post loop when parent is GONE. 2022-01-18 09:47:42 -05:00
Rashad Sookram
bf2ab74ca4 Fix radio button clicks in multiselect mode. 2022-01-14 11:19:56 -05:00
658 changed files with 27398 additions and 9089 deletions

0
.github/stale.yml vendored Normal file
View File

View File

@@ -51,6 +51,13 @@
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JAVA">
<option name="BRACE_STYLE" value="5" />
<option name="CLASS_BRACE_STYLE" value="5" />

8
.idea/file.template.settings.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExportableFileTemplateSettings">
<default_templates>
<template name="ViewModel.kt" file-name="${NAME}ViewModel" reformat="true" live-template-enabled="false" />
</default_templates>
</component>
</project>

20
.idea/fileTemplates/ViewModel.kt generated Normal file
View File

@@ -0,0 +1,20 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.util.livedata.Store
import io.reactivex.rxjava3.disposables.CompositeDisposable
#end
#parse("File Header.java")
class ${NAME}ViewModel : ViewModel() {
private val store = Store(${NAME}State())
private val disposables = CompositeDisposable()
val state: LiveData<${NAME}State> = store.stateLiveData
override fun onCleared() {
disposables.clear()
}
}

View File

@@ -62,8 +62,8 @@ ktlint {
version = "0.43.2"
}
def canonicalVersionCode = 987
def canonicalVersionName = "5.29.3"
def canonicalVersionCode = 1015
def canonicalVersionName = "5.32.12"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -75,20 +75,18 @@ def abiPostFix = ['universal' : 0,
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
def selectableVariants = [
'nightlyProdFlipper',
'nightlyProdSpinner',
'nightlyProdPerf',
'nightlyProdRelease',
'playProdDebug',
'playProdFlipper',
'playProdSpinner',
'playProdPerf',
'playProdRelease',
'playStagingDebug',
'playStagingFlipper',
'playStagingSpinner',
'playStagingPerf',
'playStagingRelease',
'studyProdMock',
'studyProdPerf',
'websiteProdFlipper',
'websiteProdSpinner',
'websiteProdRelease',
]
@@ -119,6 +117,48 @@ android {
}
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
unitTests {
includeAndroidResources = true
}
}
lintOptions {
checkReleaseBuilds false
abortOnError true
baseline file("lint-baseline.xml")
disable "LintError"
}
sourceSets {
test {
java.srcDirs += "$projectDir/src/testShared"
}
androidTest {
java.srcDirs += "$projectDir/src/testShared"
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
packagingOptions {
exclude 'LICENSE.txt'
exclude 'LICENSE'
exclude 'NOTICE'
exclude 'asm-license.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
}
defaultConfig {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
@@ -150,10 +190,14 @@ android {
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
buildConfigField "String", "CDSH_CODE_HASH", "\"ec58c0d7561de8d5657f3a4b22a635eaa305204e9359dcc80a99dfd0c5f1cbf2\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," +
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
"new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
"}"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXQ==\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
@@ -188,45 +232,13 @@ android {
testInstrumentationRunnerArguments clearPackageData: 'true'
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
sourceSets {
test {
java.srcDirs += "$projectDir/src/testShared"
}
androidTest {
java.srcDirs += "$projectDir/src/testShared"
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
packagingOptions {
exclude 'LICENSE.txt'
exclude 'LICENSE'
exclude 'NOTICE'
exclude 'asm-license.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
exclude '/org/spongycastle/x509/CertPathReviewerMessages.properties'
exclude '/org/spongycastle/x509/CertPathReviewerMessages_de.properties'
}
buildTypes {
debug {
if (keystores['debug'] != null) {
signingConfig signingConfigs.debug
}
isDefault true
minifyEnabled true
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard/proguard-firebase-messaging.pro',
'proguard/proguard-google-play-services.pro',
@@ -235,7 +247,6 @@ android {
'proguard/proguard-appcompat-v7.pro',
'proguard/proguard-square-okhttp.pro',
'proguard/proguard-square-okio.pro',
'proguard/proguard-spongycastle.pro',
'proguard/proguard-rounded-image-view.pro',
'proguard/proguard-glide.pro',
'proguard/proguard-shortcutbadger.pro',
@@ -251,12 +262,12 @@ android {
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
}
flipper {
spinner {
initWith debug
isDefault false
minifyEnabled false
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Flipper\""
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Spinner\""
}
release {
minifyEnabled true
@@ -267,16 +278,10 @@ android {
initWith debug
isDefault false
debuggable false
minifyEnabled true
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
}
mock {
initWith debug
isDefault false
minifyEnabled false
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Mock\""
}
}
productFlavors {
@@ -306,16 +311,6 @@ android {
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
}
study {
dimension 'distribution'
applicationIdSuffix ".study"
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"study\""
}
prod {
dimension 'environment'
@@ -337,10 +332,14 @@ android {
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
"new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
"}"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXQ==\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
@@ -357,6 +356,9 @@ android {
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
def tag = getCurrentGitTag()
if (tag != null && tag.length() > 0) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
output.versionNameOverride = tag
}
} else {
@@ -381,19 +383,6 @@ android {
variant.setIgnore(true)
}
}
lintOptions {
checkReleaseBuilds false
abortOnError true
baseline file("lint-baseline.xml")
disable "LintError"
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
@@ -519,9 +508,8 @@ dependencies {
}
implementation libs.dnsjava
flipperImplementation libs.facebook.flipper
flipperImplementation libs.facebook.soloader
flipperImplementation libs.square.leakcanary
spinnerImplementation project(":spinner")
spinnerImplementation libs.square.leakcanary
testImplementation testLibs.junit.junit
testImplementation testLibs.assertj.core
@@ -536,6 +524,9 @@ dependencies {
exclude group: 'com.google.protobuf', module: 'protobuf-java'
}
testImplementation testLibs.robolectric.shadows.multidex
testImplementation (testLibs.bouncycastle.bcprov.jdk15on) {
force = true
}
testImplementation testLibs.hamcrest.hamcrest
testImplementation(testFixtures(project(":libsignal-service")))

View File

@@ -0,0 +1,157 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* When writing tests, be very careful to call [DatabaseObserver.flush] before asserting any observer state. Internally, the observer is enqueueing tasks on
* an executor, and failing to flush the executor will lead to incorrect/flaky tests.
*/
@RunWith(AndroidJUnit4::class)
class DatabaseObserverTest {
private lateinit var db: SQLiteDatabase
private lateinit var observer: DatabaseObserver
@Before
fun setup() {
db = SignalDatabase.instance!!.signalWritableDatabase
observer = ApplicationDependencies.getDatabaseObserver()
}
@Test
fun notifyConversationListeners_runsImmediatelyIfNotInTransaction() {
val hasRun = AtomicBoolean(false)
observer.registerConversationObserver(1) { hasRun.set(true) }
observer.notifyConversationListeners(1)
observer.flush()
assertTrue(hasRun.get())
}
@Test
fun notifyConversationListeners_runsAfterSuccessIfInTransaction() {
val hasRun = AtomicBoolean(false)
db.beginTransaction()
observer.registerConversationObserver(1) { hasRun.set(true) }
observer.notifyConversationListeners(1)
observer.flush()
assertFalse(hasRun.get())
db.setTransactionSuccessful()
db.endTransaction()
observer.flush()
assertTrue(hasRun.get())
}
@Test
fun notifyConversationListeners_doesNotRunAfterFailedTransaction() {
val hasRun = AtomicBoolean(false)
db.beginTransaction()
observer.registerConversationObserver(1) { hasRun.set(true) }
observer.notifyConversationListeners(1)
observer.flush()
assertFalse(hasRun.get())
db.endTransaction()
observer.flush()
assertFalse(hasRun.get())
// Verifying we still don't run it even after a subsequent success
db.beginTransaction()
db.setTransactionSuccessful()
db.endTransaction()
observer.flush()
assertFalse(hasRun.get())
}
@Test
fun notifyConversationListeners_onlyRunAfterAllTransactionsComplete() {
val hasRun = AtomicBoolean(false)
db.beginTransaction()
observer.registerConversationObserver(1) { hasRun.set(true) }
observer.notifyConversationListeners(1)
observer.flush()
assertFalse(hasRun.get())
db.beginTransaction()
db.setTransactionSuccessful()
db.endTransaction()
observer.flush()
assertFalse(hasRun.get())
db.setTransactionSuccessful()
db.endTransaction()
observer.flush()
assertTrue(hasRun.get())
}
@Test
fun notifyConversationListeners_runsImmediatelyIfTheTransactionIsOnAnotherThread() {
db.beginTransaction()
val latch = CountDownLatch(1)
SignalExecutors.BOUNDED.execute {
val hasRun = AtomicBoolean(false)
observer.registerConversationObserver(1) { hasRun.set(true) }
observer.notifyConversationListeners(1)
observer.flush()
assertTrue(hasRun.get())
latch.countDown()
}
latch.await()
db.setTransactionSuccessful()
db.endTransaction()
}
@Test
fun notifyConversationListeners_runsAfterSuccessIfInTransaction_ignoreDuplicateNotifications() {
val thread1Count = AtomicInteger(0)
val thread2Count = AtomicInteger(0)
db.beginTransaction()
observer.registerConversationObserver(1) { thread1Count.incrementAndGet() }
observer.registerConversationObserver(2) { thread2Count.incrementAndGet() }
observer.notifyConversationListeners(1)
observer.notifyConversationListeners(2)
observer.notifyConversationListeners(2)
observer.flush()
assertEquals(0, thread1Count.get())
assertEquals(0, thread2Count.get())
db.setTransactionSuccessful()
db.endTransaction()
observer.flush()
assertEquals(1, thread1Count.get())
assertEquals(1, thread2Count.get())
}
}

View File

@@ -8,6 +8,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RecipientChangedNumberJob
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
@@ -17,6 +20,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import java.lang.IllegalArgumentException
import java.util.UUID
@@ -25,10 +29,16 @@ class RecipientDatabaseTest {
private lateinit var recipientDatabase: RecipientDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
ensureDbEmpty()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
}
// ==============================================================
@@ -41,7 +51,7 @@ class RecipientDatabaseTest {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireAci())
assertEquals(ACI_A, recipient.requireServiceId())
assertFalse(recipient.hasE164())
}
@@ -51,7 +61,7 @@ class RecipientDatabaseTest {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, false)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireAci())
assertEquals(ACI_A, recipient.requireServiceId())
assertFalse(recipient.hasE164())
}
@@ -62,7 +72,7 @@ class RecipientDatabaseTest {
val recipient = Recipient.resolved(recipientId)
assertEquals(E164_A, recipient.requireE164())
assertFalse(recipient.hasAci())
assertFalse(recipient.hasServiceId())
}
/** If all you have is an E164, you can just store that, regardless of trust level. */
@@ -72,7 +82,7 @@ class RecipientDatabaseTest {
val recipient = Recipient.resolved(recipientId)
assertEquals(E164_A, recipient.requireE164())
assertFalse(recipient.hasAci())
assertFalse(recipient.hasServiceId())
}
/** With high trust, you can associate an ACI-e164 pair. */
@@ -81,7 +91,7 @@ class RecipientDatabaseTest {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireAci())
assertEquals(ACI_A, recipient.requireServiceId())
assertEquals(E164_A, recipient.requireE164())
}
@@ -91,7 +101,7 @@ class RecipientDatabaseTest {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireAci())
assertEquals(ACI_A, recipient.requireServiceId())
assertFalse(recipient.hasE164())
}
@@ -102,26 +112,26 @@ class RecipientDatabaseTest {
/** With high trust, you can associate an e164 with an existing ACI. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_highTrust() {
val existingId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
}
/** With low trust, you cannot associate an ACI-e164 pair, and therefore cannot store the e164. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_lowTrust() {
val existingId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertFalse(retrievedRecipient.hasE164())
}
@@ -134,7 +144,7 @@ class RecipientDatabaseTest {
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_B, retrievedRecipient.requireE164())
}
@@ -147,7 +157,7 @@ class RecipientDatabaseTest {
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
}
@@ -164,7 +174,7 @@ class RecipientDatabaseTest {
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
}
@@ -177,28 +187,30 @@ class RecipientDatabaseTest {
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertFalse(retrievedRecipient.hasE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(E164_A, existingRecipient.requireE164())
assertFalse(existingRecipient.hasAci())
assertFalse(existingRecipient.hasServiceId())
}
/** We never change the ACI of an existing row. New ACI = new person, regardless of trust. But high trust lets us take the e164 from the current holder. */
@Test
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2_highTrust() {
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
recipientDatabase.setPni(existingId, PNI_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
recipientDatabase.setPni(retrievedId, PNI_A)
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_B, retrievedRecipient.requireAci())
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(ACI_A, existingRecipient.requireAci())
assertEquals(ACI_A, existingRecipient.requireServiceId())
assertFalse(existingRecipient.hasE164())
}
@@ -211,11 +223,11 @@ class RecipientDatabaseTest {
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_B, retrievedRecipient.requireAci())
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
assertFalse(retrievedRecipient.hasE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(ACI_A, existingRecipient.requireAci())
assertEquals(ACI_A, existingRecipient.requireServiceId())
assertEquals(E164_A, existingRecipient.requireE164())
}
@@ -234,11 +246,11 @@ class RecipientDatabaseTest {
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_B, retrievedRecipient.requireAci())
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
assertFalse(retrievedRecipient.hasE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(ACI_A, existingRecipient.requireAci())
assertEquals(ACI_A, existingRecipient.requireServiceId())
assertEquals(E164_A, existingRecipient.requireE164())
}
@@ -255,48 +267,80 @@ class RecipientDatabaseTest {
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
}
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust() {
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingAciId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(existingE164Id)
assertEquals(retrievedId, existingE164Recipient.id)
changeNumberListener.waitForJobManager()
assertFalse(changeNumberListener.numberChangeWasEnqueued)
}
/** Same as [getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust], but with a number change. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust_changedNumber() {
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingAciId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(existingE164Id)
assertEquals(retrievedId, existingE164Recipient.id)
changeNumberListener.waitForJobManager()
assert(changeNumberListener.numberChangeWasEnqueued)
}
/** Low trust means you cant merge. If youre retrieving a user from the table with this data, prefer the ACI one. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_lowTrust() {
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
assertEquals(existingAciId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertFalse(retrievedRecipient.hasE164())
val existingE164Recipient = Recipient.resolved(existingE164Id)
assertEquals(E164_A, existingE164Recipient.requireE164())
assertFalse(existingE164Recipient.hasAci())
assertFalse(existingE164Recipient.hasServiceId())
}
/** Another high trust case. No new rules here, just a more complex scenario to show how different rules interact. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex_highTrust() {
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
@@ -304,12 +348,14 @@ class RecipientDatabaseTest {
assertEquals(existingId1, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingRecipient2 = Recipient.resolved(existingId2)
assertEquals(ACI_B, existingRecipient2.requireAci())
assertEquals(ACI_B, existingRecipient2.requireServiceId())
assertFalse(existingRecipient2.hasE164())
assert(changeNumberListener.numberChangeWasEnqueued)
}
/** Another low trust case. No new rules here, just a more complex scenario to show how different rules interact. */
@@ -322,11 +368,11 @@ class RecipientDatabaseTest {
assertEquals(existingId1, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_B, retrievedRecipient.requireE164())
val existingRecipient2 = Recipient.resolved(existingId2)
assertEquals(ACI_B, existingRecipient2.requireAci())
assertEquals(ACI_B, existingRecipient2.requireServiceId())
assertEquals(E164_A, existingRecipient2.requireE164())
}
@@ -343,7 +389,7 @@ class RecipientDatabaseTest {
assertEquals(existingId1, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
assertFalse(recipientDatabase.getByE164(E164_B).isPresent)
@@ -368,14 +414,71 @@ class RecipientDatabaseTest {
assertEquals(existingId2, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertFalse(retrievedRecipient.hasE164())
val recipientWithId1 = Recipient.resolved(existingId1)
assertEquals(ACI_B, recipientWithId1.requireAci())
assertEquals(ACI_B, recipientWithId1.requireServiceId())
assertEquals(E164_A, recipientWithId1.requireE164())
}
/** This is a case where normally we'd update the E164 of a user, but here the changeSelf flag is disabled, so we shouldn't. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciBelongsToLocalUser_highTrust_changeSelfFalse() {
val dataSet = KeyValueDataSet().apply {
putString(AccountValues.KEY_E164, E164_A)
putString(AccountValues.KEY_ACI, ACI_A.toString())
}
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, highTrust = true, changeSelf = false)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
}
/** This is a case where we're changing our own number, and it's allowed because changeSelf = true. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciBelongsToLocalUser_highTrust_changeSelfTrue() {
val dataSet = KeyValueDataSet().apply {
putString(AccountValues.KEY_E164, E164_A)
putString(AccountValues.KEY_ACI, ACI_A.toString())
}
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, highTrust = true, changeSelf = true)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_B, retrievedRecipient.requireE164())
}
/** Verifying a case where a change number job is expected to be enqueued. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_highTrust_changedNumber() {
val changeNumberListener = ChangeNumberListener()
changeNumberListener.enqueue()
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_B, retrievedRecipient.requireE164())
changeNumberListener.waitForJobManager()
assert(changeNumberListener.numberChangeWasEnqueued)
}
// ==============================================================
// Misc
// ==============================================================
@@ -400,18 +503,18 @@ class RecipientDatabaseTest {
@Test
fun createByUuidSanityCheck() {
// GIVEN one recipient
val recipientId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
val recipientId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
// WHEN I retrieve one by UUID
val possible: Optional<RecipientId> = recipientDatabase.getByAci(ACI_A)
val possible: Optional<RecipientId> = recipientDatabase.getByServiceId(ACI_A)
// THEN I get it back, and it has the properties I expect
assertTrue(possible.isPresent)
assertEquals(recipientId, possible.get())
val recipient = Recipient.resolved(recipientId)
assertTrue(recipient.aci.isPresent)
assertEquals(ACI_A, recipient.aci.get())
assertTrue(recipient.serviceId.isPresent)
assertEquals(ACI_A, recipient.serviceId.get())
}
@Test(expected = IllegalArgumentException::class)
@@ -426,10 +529,31 @@ class RecipientDatabaseTest {
}
}
private class ChangeNumberListener {
var numberChangeWasEnqueued = false
private set
fun waitForJobManager() {
ApplicationDependencies.getJobManager().flush()
ThreadUtil.sleep(500)
}
fun enqueue() {
ApplicationDependencies.getJobManager().addListener(
{ job -> job.factoryKey == RecipientChangedNumberJob.KEY },
{ _, _ -> numberChangeWasEnqueued = true }
)
}
}
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
}

View File

@@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
@@ -33,6 +34,7 @@ import org.whispersystems.libsignal.SignalProtocolAddress
import org.whispersystems.libsignal.state.SessionRecord
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID
@@ -51,6 +53,9 @@ class RecipientDatabaseTest_merges {
private lateinit var reactionDatabase: ReactionDatabase
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
@@ -65,6 +70,9 @@ class RecipientDatabaseTest_merges {
reactionDatabase = SignalDatabase.reactions
notificationProfileDatabase = SignalDatabase.notificationProfiles
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
ensureDbEmpty()
}
@@ -72,9 +80,9 @@ class RecipientDatabaseTest_merges {
@Test
fun getAndPossiblyMerge_general() {
// Setup
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_B)
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
@@ -99,7 +107,7 @@ class RecipientDatabaseTest_merges {
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
sessionDatabase.store(SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
@@ -119,7 +127,7 @@ class RecipientDatabaseTest_merges {
// Recipient validation
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(recipientIdE164)
@@ -175,7 +183,7 @@ class RecipientDatabaseTest_merges {
assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
// Session validation
assertNotNull(sessionDatabase.load(SignalProtocolAddress(ACI_A.toString(), 1)))
assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
// Reaction validation
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))

View File

@@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.concurrent.SignalExecutors
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicBoolean
/**
* These are tests for the wrapper we wrote around SQLCipherDatabase, not the stock or SQLCipher one.
*/
@RunWith(AndroidJUnit4::class)
class SQLiteDatabaseTest {
private lateinit var db: SQLiteDatabase
@Before
fun setup() {
db = SignalDatabase.instance!!.signalWritableDatabase
}
@Test
fun runPostSuccessfulTransaction_runsImmediatelyIfNotInTransaction() {
val hasRun = AtomicBoolean(false)
db.runPostSuccessfulTransaction { hasRun.set(true) }
assertTrue(hasRun.get())
}
@Test
fun runPostSuccessfulTransaction_runsAfterSuccessIfInTransaction() {
val hasRun = AtomicBoolean(false)
db.beginTransaction()
db.runPostSuccessfulTransaction { hasRun.set(true) }
assertFalse(hasRun.get())
db.setTransactionSuccessful()
db.endTransaction()
assertTrue(hasRun.get())
}
@Test
fun runPostSuccessfulTransaction_doesNotRunAfterFailedTransaction() {
val hasRun = AtomicBoolean(false)
db.beginTransaction()
db.runPostSuccessfulTransaction { hasRun.set(true) }
assertFalse(hasRun.get())
db.endTransaction()
assertFalse(hasRun.get())
// Verifying we still don't run it even after a subsequent success
db.beginTransaction()
db.setTransactionSuccessful()
db.endTransaction()
assertFalse(hasRun.get())
}
@Test
fun runPostSuccessfulTransaction_onlyRunAfterAllTransactionsComplete() {
val hasRun = AtomicBoolean(false)
db.beginTransaction()
db.runPostSuccessfulTransaction { hasRun.set(true) }
assertFalse(hasRun.get())
db.beginTransaction()
db.setTransactionSuccessful()
db.endTransaction()
assertFalse(hasRun.get())
db.setTransactionSuccessful()
db.endTransaction()
assertTrue(hasRun.get())
}
@Test
fun runPostSuccessfulTransaction_runsImmediatelyIfTheTransactionIsOnAnotherThread() {
db.beginTransaction()
val latch = CountDownLatch(1)
SignalExecutors.BOUNDED.execute {
val hasRun = AtomicBoolean(false)
db.runPostSuccessfulTransaction { hasRun.set(true) }
assertTrue(hasRun.get())
latch.countDown()
}
latch.await()
db.setTransactionSuccessful()
db.endTransaction()
}
@Test
fun runPostSuccessfulTransaction_runsAfterSuccessIfInTransaction_ignoreDuplicates() {
val hasRun1 = AtomicBoolean(false)
val hasRun2 = AtomicBoolean(false)
db.beginTransaction()
db.runPostSuccessfulTransaction("key") { hasRun1.set(true) }
db.runPostSuccessfulTransaction("key") { hasRun2.set(true) }
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
db.setTransactionSuccessful()
db.endTransaction()
assertTrue(hasRun1.get())
assertFalse(hasRun2.get())
}
}

View File

@@ -1,271 +0,0 @@
package org.thoughtcrime.securesms.database;
import android.app.Application;
import android.content.Context;
import android.database.Cursor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.flipper.plugins.databases.DatabaseDescriptor;
import com.facebook.flipper.plugins.databases.DatabaseDriver;
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.util.Hex;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* A lot of this code is taken from {@link com.facebook.flipper.plugins.databases.impl.SqliteDatabaseDriver}
* and made to work with SqlCipher. Unfortunately I couldn't use it directly, nor subclass it.
*/
public class FlipperSqlCipherAdapter extends DatabaseDriver<FlipperSqlCipherAdapter.Descriptor> {
private static final String TAG = Log.tag(FlipperSqlCipherAdapter.class);
public FlipperSqlCipherAdapter(Context context) {
super(context);
}
@Override
public List<Descriptor> getDatabases() {
try {
SignalDatabaseOpenHelper mainOpenHelper = Objects.requireNonNull(SignalDatabase.getInstance());
SignalDatabaseOpenHelper keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext());
SignalDatabaseOpenHelper megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext());
SignalDatabaseOpenHelper jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext());
SignalDatabaseOpenHelper metricsOpenHelper = LocalMetricsDatabase.getInstance((Application) getContext());
return Arrays.asList(new Descriptor(mainOpenHelper),
new Descriptor(keyValueOpenHelper),
new Descriptor(megaphoneOpenHelper),
new Descriptor(jobManagerOpenHelper),
new Descriptor(metricsOpenHelper));
} catch (Exception e) {
Log.i(TAG, "Unable to use reflection to access raw database.", e);
}
return Collections.emptyList();
}
@Override
public List<String> getTableNames(Descriptor descriptor) {
SQLiteDatabase db = descriptor.getReadable();
List<String> tableNames = new ArrayList<>();
try (Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type IN (?, ?)", new String[] { "table", "view" })) {
while (cursor != null && cursor.moveToNext()) {
tableNames.add(cursor.getString(0));
}
}
return tableNames;
}
@Override
public DatabaseGetTableDataResponse getTableData(Descriptor descriptor, String table, String order, boolean reverse, int start, int count) {
SQLiteDatabase db = descriptor.getReadable();
long total = DatabaseUtils.queryNumEntries(db, table);
String orderBy = order != null ? order + (reverse ? " DESC" : " ASC") : null;
String limitBy = start + ", " + count;
try (Cursor cursor = db.query(table, null, null, null, null, null, orderBy, limitBy)) {
String[] columnNames = cursor.getColumnNames();
List<List<Object>> rows = cursorToList(cursor);
return new DatabaseGetTableDataResponse(Arrays.asList(columnNames), rows, start, rows.size(), total);
}
}
@Override
public DatabaseGetTableStructureResponse getTableStructure(Descriptor descriptor, String table) {
SQLiteDatabase db = descriptor.getReadable();
Map<String, String> foreignKeyValues = new HashMap<>();
try(Cursor cursor = db.rawQuery("PRAGMA foreign_key_list(" + table + ")", null)) {
while (cursor != null && cursor.moveToNext()) {
String from = cursor.getString(cursor.getColumnIndex("from"));
String to = cursor.getString(cursor.getColumnIndex("to"));
String tableName = cursor.getString(cursor.getColumnIndex("table")) + "(" + to + ")";
foreignKeyValues.put(from, tableName);
}
}
List<String> structureColumns = Arrays.asList("column_name", "data_type", "nullable", "default", "primary_key", "foreign_key");
List<List<Object>> structureValues = new ArrayList<>();
try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) {
while (cursor != null && cursor.moveToNext()) {
String columnName = cursor.getString(cursor.getColumnIndex("name"));
String foreignKey = foreignKeyValues.containsKey(columnName) ? foreignKeyValues.get(columnName) : null;
structureValues.add(Arrays.asList(columnName,
cursor.getString(cursor.getColumnIndex("type")),
cursor.getInt(cursor.getColumnIndex("notnull")) == 0,
getObjectFromColumnIndex(cursor, cursor.getColumnIndex("dflt_value")),
cursor.getInt(cursor.getColumnIndex("pk")) == 1,
foreignKey));
}
}
List<String> indexesColumns = Arrays.asList("index_name", "unique", "indexed_column_name");
List<List<Object>> indexesValues = new ArrayList<>();
try (Cursor indexesCursor = db.rawQuery("PRAGMA index_list(" + table + ")", null)) {
List<String> indexedColumnNames = new ArrayList<>();
String indexName = indexesCursor.getString(indexesCursor.getColumnIndex("name"));
try(Cursor indexInfoCursor = db.rawQuery("PRAGMA index_info(" + indexName + ")", null)) {
while (indexInfoCursor.moveToNext()) {
indexedColumnNames.add(indexInfoCursor.getString(indexInfoCursor.getColumnIndex("name")));
}
}
indexesValues.add(Arrays.asList(indexName,
indexesCursor.getInt(indexesCursor.getColumnIndex("unique")) == 1,
TextUtils.join(",", indexedColumnNames)));
}
return new DatabaseGetTableStructureResponse(structureColumns, structureValues, indexesColumns, indexesValues);
}
@Override
public DatabaseGetTableInfoResponse getTableInfo(Descriptor databaseDescriptor, String table) {
SQLiteDatabase db = databaseDescriptor.getReadable();
try (Cursor cursor = db.rawQuery("SELECT sql FROM sqlite_master WHERE name = ?", new String[] { table })) {
cursor.moveToFirst();
return new DatabaseGetTableInfoResponse(cursor.getString(cursor.getColumnIndex("sql")));
}
}
@Override
public DatabaseExecuteSqlResponse executeSQL(Descriptor descriptor, String query) {
SQLiteDatabase db = descriptor.getWritable();
String firstWordUpperCase = getFirstWord(query).toUpperCase();
switch (firstWordUpperCase) {
case "UPDATE":
case "DELETE":
return executeUpdateDelete(db, query);
case "INSERT":
return executeInsert(db, query);
case "SELECT":
case "PRAGMA":
case "EXPLAIN":
return executeSelect(db, query);
default:
return executeRawQuery(db, query);
}
}
private static String getFirstWord(String s) {
s = s.trim();
int firstSpace = s.indexOf(' ');
return firstSpace >= 0 ? s.substring(0, firstSpace) : s;
}
private static DatabaseExecuteSqlResponse executeUpdateDelete(SQLiteDatabase database, String query) {
SQLiteStatement statement = database.compileStatement(query);
int count = statement.executeUpdateDelete();
return DatabaseExecuteSqlResponse.successfulUpdateDelete(count);
}
private static DatabaseExecuteSqlResponse executeInsert(SQLiteDatabase database, String query) {
SQLiteStatement statement = database.compileStatement(query);
long insertedId = statement.executeInsert();
return DatabaseExecuteSqlResponse.successfulInsert(insertedId);
}
private static DatabaseExecuteSqlResponse executeSelect(SQLiteDatabase database, String query) {
try (Cursor cursor = database.rawQuery(query, null)) {
String[] columnNames = cursor.getColumnNames();
List<List<Object>> rows = cursorToList(cursor);
return DatabaseExecuteSqlResponse.successfulSelect(Arrays.asList(columnNames), rows);
}
}
private static DatabaseExecuteSqlResponse executeRawQuery(SQLiteDatabase database, String query) {
database.execSQL(query);
return DatabaseExecuteSqlResponse.successfulRawQuery();
}
private static @NonNull List<List<Object>> cursorToList(Cursor cursor) {
List<List<Object>> rows = new ArrayList<>();
int numColumns = cursor.getColumnCount();
while (cursor.moveToNext()) {
List<Object> values = new ArrayList<>(numColumns);
for (int column = 0; column < numColumns; column++) {
values.add(getObjectFromColumnIndex(cursor, column));
}
rows.add(values);
}
return rows;
}
private static @Nullable Object getObjectFromColumnIndex(Cursor cursor, int column) {
switch (cursor.getType(column)) {
case Cursor.FIELD_TYPE_NULL:
return null;
case Cursor.FIELD_TYPE_INTEGER:
return cursor.getLong(column);
case Cursor.FIELD_TYPE_FLOAT:
return cursor.getDouble(column);
case Cursor.FIELD_TYPE_BLOB:
byte[] blob = cursor.getBlob(column);
String bytes = blob != null ? "(blob) " + Hex.toStringCondensed(Arrays.copyOf(blob, Math.min(blob.length, 32))) : null;
if (bytes != null && bytes.length() == 32 && blob.length > 32) {
bytes += "...";
}
return bytes;
case Cursor.FIELD_TYPE_STRING:
default:
return cursor.getString(column);
}
}
static class Descriptor implements DatabaseDescriptor {
private final SignalDatabaseOpenHelper sqlCipherOpenHelper;
Descriptor(@NonNull SignalDatabaseOpenHelper sqlCipherOpenHelper) {
this.sqlCipherOpenHelper = sqlCipherOpenHelper;
}
@Override
public String name() {
return sqlCipherOpenHelper.getDatabaseName();
}
public @NonNull SQLiteDatabase getReadable() {
return sqlCipherOpenHelper.getSqlCipherDatabase();
}
public @NonNull SQLiteDatabase getWritable() {
return sqlCipherOpenHelper.getSqlCipherDatabase();
}
}
}

View File

@@ -308,8 +308,6 @@
android:allowEmbedded="true"
android:resizeableActivity="true" />
<activity android:name=".longmessage.LongMessageActivity" />
<activity android:name=".conversation.ConversationPopupActivity"
android:windowSoftInputMode="stateVisible"
android:launchMode="singleTask"
@@ -318,12 +316,6 @@
android:theme="@style/TextSecure.LightTheme.Popup"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
<activity android:name=".messagedetails.MessageDetailsActivity"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTask"
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"
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
@@ -428,7 +420,7 @@
<activity android:name=".registration.RegistrationNavigationActivity"
android:launchMode="singleTask"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="stateUnchanged"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".revealable.ViewOnceMessageActivity"

View File

@@ -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 {
@@ -13,12 +12,13 @@ public final class AppCapabilities {
private static final boolean GV1_MIGRATION = true;
private static final boolean ANNOUNCEMENT_GROUPS = true;
private static final boolean SENDER_KEY = true;
private static final boolean CHANGE_NUMBER = 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, SENDER_KEY, ANNOUNCEMENT_GROUPS, FeatureFlags.changeNumber());
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER);
}
}

View File

@@ -52,9 +52,11 @@ public final class AppInitialization {
Log.i(TAG, "onPostBackupRestore()");
ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
SignalStore.onPostBackupRestore();
SignalStore.onFirstEverAppLaunch();
SignalStore.onboarding().clearAll();
TextSecurePreferences.onPostBackupRestore(context);
TextSecurePreferences.setPasswordDisabled(context, true);
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false));

View File

@@ -35,6 +35,8 @@ import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
@@ -174,7 +176,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
.addNonBlocking(this::initializeFcmCheck)
.addNonBlocking(this::initializeSignedPreKeyCheck)
.addNonBlocking(CreateSignedPreKeyJob::enqueueIfNeeded)
.addNonBlocking(this::initializePeriodicTasks)
.addNonBlocking(this::initializeCircumvention)
.addNonBlocking(this::initializePendingMessages)
@@ -192,6 +194,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveReleaseChannelJob::enqueue)
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -348,12 +352,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
}
private void initializeSignedPreKeyCheck() {
if (!TextSecurePreferences.isSignedPreKeyRegistered(this)) {
ApplicationDependencies.getJobManager().add(new CreateSignedPreKeyJob(this));
}
}
private void initializeExpiringMessageManager() {
ApplicationDependencies.getExpiringMessageManager().checkSchedule();
}

View File

@@ -92,6 +92,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord);
void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted);
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
void onCallToAction(@NonNull String action);
void onDonateClicked();
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);

View File

@@ -76,6 +76,11 @@ public final class BlockUnblockDialog {
builder.setPositiveButton(R.string.RecipientPreferenceActivity_block, ((dialog, which) -> onBlock.run()));
builder.setNegativeButton(android.R.string.cancel, null);
}
} else if (recipient.isReleaseNotes()) {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_block_getting_signal_updates_and_news);
builder.setPositiveButton(R.string.BlockUnblockDialog_block, ((dialog, which) -> onBlock.run()));
builder.setNegativeButton(android.R.string.cancel, null);
} else {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
@@ -115,6 +120,12 @@ public final class BlockUnblockDialog {
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
builder.setNegativeButton(android.R.string.cancel, null);
}
} else if (recipient.isReleaseNotes()) {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_resume_getting_signal_updates_and_news);
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
builder.setNegativeButton(android.R.string.cancel, null);
} else {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other);

View File

@@ -571,11 +571,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameUtil.fetchAciForUsername(requireContext(), contact.getNumber());
return UsernameUtil.fetchAciForUsername(contact.getNumber());
}, uuid -> {
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
if (onContactSelectedListener != null) {
@@ -746,7 +746,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
return;
}
TransitionManager.beginDelayedTransition(constraintLayout, new AutoTransition().setDuration(CHIP_GROUP_REVEAL_DURATION_MS));
AutoTransition transition = new AutoTransition();
transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS);
transition.excludeChildren(recyclerView, true);
transition.excludeTarget(recyclerView, true);
TransitionManager.beginDelayedTransition(constraintLayout, transition);
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(constraintLayout);

View File

@@ -21,9 +21,11 @@ import androidx.core.content.ContextCompat;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.qr.ScanListener;
import org.thoughtcrime.securesms.util.Base64;
@@ -186,12 +188,13 @@ public class DeviceActivity extends PassphraseRequiredActivity
return BAD_CODE;
}
ECPublicKey publicKey = Curve.decodePoint(Base64.decode(publicKeyEncoded), 0);
IdentityKeyPair identityKeyPair = IdentityKeyUtil.getIdentityKeyPair(context);
Optional<byte[]> profileKey = Optional.of(ProfileKeyUtil.getProfileKey(getContext()));
ECPublicKey publicKey = Curve.decodePoint(Base64.decode(publicKeyEncoded), 0);
IdentityKeyPair aciIdentityKeyPair = SignalStore.account().getAciIdentityKey();
IdentityKeyPair pniIdentityKeyPair = SignalStore.account().getPniIdentityKey();
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
TextSecurePreferences.setMultiDevice(DeviceActivity.this, true);
accountManager.addDevice(ephemeralId, publicKey, identityKeyPair, profileKey, verificationCode);
accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, verificationCode);
return SUCCESS;
} catch (NotFoundException e) {

View File

@@ -21,13 +21,11 @@ import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
@@ -557,9 +555,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
int mediaPosition = Objects.requireNonNull(data.second);
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(),this, cursor, mediaPosition, leftIsRecent);
mediaPager.setAdapter(adapter);
adapter.setActive(true);
CursorPagerAdapter oldAdapter = (CursorPagerAdapter) mediaPager.getAdapter();
if (oldAdapter == null) {
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(), this, cursor, mediaPosition, leftIsRecent);
mediaPager.setAdapter(adapter);
adapter.setActive(true);
} else {
oldAdapter.setCursor(cursor, mediaPosition);
oldAdapter.setActive(true);
}
viewModel.setCursor(this, cursor, leftIsRecent);
@@ -715,10 +719,10 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
private final Map<Integer, MediaPreviewFragment> mediaFragments = new HashMap<>();
private final Context context;
private final Cursor cursor;
private final boolean leftIsRecent;
private boolean active;
private Cursor cursor;
private int autoPlayPosition;
CursorPagerAdapter(@NonNull FragmentManager fragmentManager,
@@ -739,6 +743,11 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
notifyDataSetChanged();
}
public void setCursor(@NonNull Cursor cursor, int autoPlayPosition) {
this.cursor = cursor;
this.autoPlayPosition = autoPlayPosition;
}
@Override
public int getCount() {
if (!active) return 0;

View File

@@ -67,7 +67,7 @@ public class NewConversationActivity extends ContactSelectionActivity
} else {
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
if (SignalStore.account().isRegistered() && NetworkConstraint.isMet(this)) {
if (SignalStore.account().isRegistered() && NetworkConstraint.isMet(getApplication())) {
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
AlertDialog progress = SimpleProgressDialog.show(this);
@@ -75,7 +75,7 @@ public class NewConversationActivity extends ContactSelectionActivity
SimpleTask.run(getLifecycle(), () -> {
Recipient resolved = Recipient.external(this, number);
if (!resolved.isRegistered() || !resolved.hasAci()) {
if (!resolved.isRegistered() || !resolved.hasServiceId()) {
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
try {
DirectoryHelper.refreshDirectoryFor(this, resolved, false);

View File

@@ -22,6 +22,7 @@ import android.os.Bundle;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.VersionTracker;
/**
@@ -61,7 +62,8 @@ public class PassphraseCreateActivity extends PassphraseActivity {
passphrase);
MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret);
IdentityKeyUtil.generateIdentityKeys(PassphraseCreateActivity.this);
SignalStore.account().generateAciIdentityKey();
SignalStore.account().generatePniIdentityKey();
VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this);
return null;

View File

@@ -52,7 +52,7 @@ public class AudioRecorder {
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
recorder = Build.VERSION.SDK_INT >= 26 && FeatureFlags.voiceNoteRecordingV2() ? new MediaRecorderWrapper() : new AudioCodec();
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
recorder.start(fds[1]);
} catch (IOException e) {
Log.w(TAG, e);

View File

@@ -143,11 +143,10 @@ object Avatars {
)
data class ColorPair(
val backgroundAvatarColor: AvatarColor,
val foregroundAvatarColor: ForegroundColor
@ColorInt val backgroundColor: Int,
@ColorInt val foregroundColor: Int,
val code: String
) {
@ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
@ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
val code: String = backgroundAvatarColor.serialize()
constructor(backgroundAvatarColor: AvatarColor, foregroundAvatarColor: ForegroundColor) : this(backgroundAvatarColor.colorInt(), foregroundAvatarColor.colorInt, backgroundAvatarColor.serialize())
}
}

View File

@@ -244,4 +244,8 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
.execute()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
}

View File

@@ -27,10 +27,12 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
import org.thoughtcrime.securesms.database.PendingRetryReceiptDatabase;
import org.thoughtcrime.securesms.database.ReactionDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SenderKeyDatabase;
import org.thoughtcrime.securesms.database.SenderKeySharedDatabase;
@@ -39,10 +41,13 @@ 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.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.SetUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -158,13 +163,17 @@ public class FullBackupExporter extends FullBackupBase {
for (String table : tables) {
throwIfCanceled(cancellationSignal);
if (table.equals(MmsDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, estimatedCount, cancellationSignal);
count = exportTable(table, input, outputStream, cursor -> isNonExpiringMmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, estimatedCount, cancellationSignal);
count = exportTable(table, input, outputStream, cursor -> isNonExpiringSmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(ReactionDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionDatabase.MESSAGE_ID), CursorUtil.requireBoolean(cursor, ReactionDatabase.IS_MMS))), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(MentionDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal);
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
@@ -173,12 +182,6 @@ public class FullBackupExporter extends FullBackupBase {
stopwatch.split("table::" + table);
}
for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
throwIfCanceled(cancellationSignal);
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(preference);
}
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
throwIfCanceled(cancellationSignal);
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
@@ -439,7 +442,12 @@ public class FullBackupExporter extends FullBackupBase {
Class<?> type = dataSet.getType(key);
if (type == byte[].class) {
builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
byte[] data = dataSet.getBlob(key, null);
if (data != null) {
builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
} else {
Log.w(TAG, "Skipping storing null blob for key: " + key);
}
} else if (type == Boolean.class) {
builder.setBooleanValue(dataSet.getBoolean(key, false));
} else if (type == Float.class) {
@@ -449,7 +457,12 @@ public class FullBackupExporter extends FullBackupBase {
} else if (type == Long.class) {
builder.setLongValue(dataSet.getLong(key, 0));
} else if (type == String.class) {
builder.setStringValue(dataSet.getString(key, null));
String data = dataSet.getString(key, null);
if (data != null) {
builder.setStringValue(dataSet.getString(key, null));
} else {
Log.w(TAG, "Skipping storing null string for key: " + key);
}
} else {
throw new AssertionError("Unknown type: " + type);
}
@@ -470,21 +483,46 @@ public class FullBackupExporter extends FullBackupBase {
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
}
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
String where = MmsDatabase.ID + " = ?";
String[] args = new String[] { String.valueOf(mmsId) };
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, @NonNull MessageId messageId) {
if (messageId.isMms()) {
return isForNonExpiringMmsMessageAndNotReleaseChannel(db, messageId.getId());
} else {
return isForNonExpiringSmsMessage(db, messageId.getId());
}
}
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
if (mmsCursor != null && mmsCursor.moveToFirst()) {
return mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)) == 0 &&
mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 0;
private static boolean isForNonExpiringSmsMessage(@NonNull SQLiteDatabase db, long smsId) {
String[] columns = new String[] { SmsDatabase.EXPIRES_IN };
String where = SmsDatabase.ID + " = ?";
String[] args = new String[] { String.valueOf(smsId) };
try (Cursor cursor = db.query(SmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return isNonExpiringSmsMessage(cursor);
}
}
return false;
}
private static boolean isForNonExpiringMmsMessageAndNotReleaseChannel(@NonNull SQLiteDatabase db, long mmsId) {
String[] columns = new String[] { MmsDatabase.RECIPIENT_ID, MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
String where = MmsDatabase.ID + " = ?";
String[] args = new String[] { String.valueOf(mmsId) };
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
if (mmsCursor != null && mmsCursor.moveToFirst()) {
return isNonExpiringMmsMessage(mmsCursor) && isNotReleaseChannel(mmsCursor);
}
}
return false;
}
private static boolean isNotReleaseChannel(Cursor cursor) {
RecipientId releaseChannel = SignalStore.releaseChannelValues().getReleaseChannelRecipientId();
return releaseChannel == null || cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)) != releaseChannel.toLong();
}
private static class BackupFrameOutputStream extends BackupStream {

View File

@@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BackupUtil;
@@ -207,7 +208,7 @@ public class FullBackupImporter extends FullBackupBase {
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
if (avatar.hasRecipientId()) {
RecipientId recipientId = RecipientId.from(avatar.getRecipientId());
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId), avatar.getLength());
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.getLength());
} else {
if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");
@@ -250,6 +251,17 @@ public class FullBackupImporter extends FullBackupBase {
private static void processPreference(@NonNull Context context, SharedPreference preference) {
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
// Identity keys were moved from shared prefs into SignalStore. Need to handle importing backups made before the migration.
if ("SecureSMS-Preferences".equals(preference.getFile())) {
if ("pref_identity_public_v3".equals(preference.getKey()) && preference.hasValue()) {
SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.getValue());
} else if ("pref_identity_private_v3".equals(preference.getKey()) && preference.hasValue()) {
SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.getValue());
}
return;
}
if (preference.hasValue()) {
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
} else if (preference.hasBooleanValue()) {

View File

@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.core.content.ContextCompat
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.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
SplashImage.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
return configure {
customPref(SplashImage.Model(R.drawable.ic_card_process))
sectionHeaderPref(
title = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment, DSLSettingsText.CenterModifier)
)
textPref(
summary = DSLSettingsText.from(
requireContext().getString(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble),
DSLSettingsText.LearnMoreModifier(ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.donation_decline_code_error_url))
},
DSLSettingsText.CenterModifier
)
)
primaryButton(
text = DSLSettingsText.from(android.R.string.ok)
) {
dismissAllowingStateLoss()
}
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again)
) {
SignalStore.donationsValues().showCantProcessDialog = false
}
}
}
}

View File

@@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
@@ -27,9 +28,13 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
}
private fun getConfiguration(): DSLConfiguration {
val badge: Badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge
val cancellationReason: UnexpectedSubscriptionCancellation? = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
return configure {
customPref(ExpiredBadge.Model(badge))
@@ -50,8 +55,10 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
DSLSettingsText.from(
if (badge.isBoost()) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired)
} else if (inactive) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_automatically, badge.name)
} else {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer, badge.name)
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_canceled)
},
DSLSettingsText.CenterModifier
)
@@ -109,8 +116,8 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
companion object {
@JvmStatic
fun show(badge: Badge, fragmentManager: FragmentManager) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge).build()
fun show(badge: Badge, cancellationReason: UnexpectedSubscriptionCancellation?, fragmentManager: FragmentManager) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status).build()
val fragment = ExpiredBadgeBottomSheetDialogFragment()
fragment.arguments = args.toBundle()

View File

@@ -38,7 +38,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _, isFaded ->
if (badge.isExpired() || isFaded) {
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null))
} else {
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
}

View File

@@ -1,9 +1,6 @@
package org.thoughtcrime.securesms.blocked;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.Color;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -15,6 +12,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.BlockUnblockDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -74,24 +72,9 @@ public class BlockedUsersFragment extends Fragment {
}
private void handleRecipientClicked(@NonNull Recipient recipient) {
AlertDialog confirmationDialog = new AlertDialog.Builder(requireContext())
.setTitle(R.string.BlockedUsersActivity__unblock_user)
.setMessage(getString(R.string.BlockedUsersActivity__do_you_want_to_unblock_s, recipient.getDisplayName(requireContext())))
.setPositiveButton(R.string.BlockedUsersActivity__unblock, (dialog, which) -> {
viewModel.unblock(recipient.getId());
dialog.dismiss();
})
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
dialog.dismiss();
})
.setCancelable(true)
.create();
confirmationDialog.setOnShowListener(dialog -> {
confirmationDialog.getButton(DialogInterface.BUTTON_POSITIVE).setTextColor(Color.RED);
BlockUnblockDialog.showUnblockFor(requireContext(), getViewLifecycleOwner().getLifecycle(), recipient, () -> {
viewModel.unblock(recipient.getId());
});
confirmationDialog.show();
}
interface Listener {

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.Spannable;
import android.text.SpannableString;
@@ -18,6 +17,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -44,7 +44,7 @@ public class FromTextView extends SimpleEmojiTextView {
}
public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
setText(recipient, recipient.getDisplayName(getContext()), read, suffix);
setText(recipient, recipient.getDisplayNameOrUsername(getContext()), read, suffix);
}
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix) {
@@ -62,11 +62,19 @@ public class FromTextView extends SimpleEmojiTextView {
builder.append(suffix);
}
if (recipient.isReleaseNotes()) {
Drawable official = ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_20);
official.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20));
builder.append(" ")
.append(SpanUtil.buildCenteredImageSpan(official));
}
setText(builder);
if (recipient.isBlocked()) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
else if (recipient.isMuted()) setCompoundDrawablesRelativeWithIntrinsicBounds(getMuted(), null, null, null);
else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
else setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0);
}
private Drawable getMuted() {

View File

@@ -35,7 +35,11 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);
toolbar.setTitle(getTitle());
if (getTitle() != -1) {
toolbar.setTitle(getTitle());
}
toolbar.setNavigationOnClickListener(v -> onNavigateUp());
return view;
}

View File

@@ -354,13 +354,13 @@ public class InputPanel extends LinearLayout
slideToCancel.display();
if (emojiVisible) {
ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
fadeOut(mediaKeyboard);
}
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start();
fadeOut(composeText);
fadeOut(quickCameraToggle);
fadeOut(quickAudioToggle);
fadeOut(buttonToggle);
}
@Override
@@ -401,7 +401,7 @@ public class InputPanel extends LinearLayout
public void onRecordLocked() {
slideToCancel.hide();
recordLockCancel.setVisibility(View.VISIBLE);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
fadeIn(buttonToggle);
if (listener != null) listener.onRecorderLocked();
}
@@ -488,36 +488,33 @@ public class InputPanel extends LinearLayout
private void hideNormalComposeViews() {
if (emojiVisible) {
Animation animation = mediaKeyboard.getAnimation();
if (animation != null) {
animation.cancel();
}
mediaKeyboard.setVisibility(View.INVISIBLE);
mediaKeyboard.animate().cancel();
mediaKeyboard.setAlpha(0f);
}
for (Animation animation : Arrays.asList(composeText.getAnimation(), quickCameraToggle.getAnimation(), quickAudioToggle.getAnimation())) {
if (animation != null) {
animation.cancel();
}
for (View view : Arrays.asList(composeText, quickCameraToggle, quickAudioToggle, buttonToggle)) {
view.animate().cancel();
view.setAlpha(0f);
}
buttonToggle.animate().cancel();
composeText.setVisibility(View.INVISIBLE);
quickCameraToggle.setVisibility(View.INVISIBLE);
quickAudioToggle.setVisibility(View.INVISIBLE);
}
private void fadeInNormalComposeViews() {
if (emojiVisible) {
ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
fadeIn(mediaKeyboard);
}
ViewUtil.fadeIn(composeText, FADE_TIME);
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
fadeIn(composeText);
fadeIn(quickCameraToggle);
fadeIn(quickAudioToggle);
fadeIn(buttonToggle);
}
private void fadeIn(@NonNull View v) {
v.animate().alpha(1).setDuration(FADE_TIME).start();
}
private void fadeOut(@NonNull View v) {
v.animate().alpha(0).setDuration(FADE_TIME).start();
}
private void updateVisibility() {

View File

@@ -16,7 +16,6 @@ import androidx.annotation.Nullable;
import java.util.Arrays;
import kotlin.jvm.functions.Function1;
import kotlin.jvm.functions.Function2;
/**
@@ -121,7 +120,11 @@ public final class RotatableGradientDrawable extends Drawable {
public void draw(Canvas canvas) {
int save = canvas.save();
canvas.rotate(degrees, getBounds().width() / 2f, getBounds().height() / 2f);
canvas.drawRect(fillRect, fillPaint);
int height = fillRect.height();
int width = fillRect.width();
canvas.drawRect(fillRect.left - width, fillRect.top - height, fillRect.right + width, fillRect.bottom + height, fillPaint);
canvas.restoreToCount(save);
}

View File

@@ -1,18 +1,27 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Emoji {
private final List<String> variations;
private final List<String> rawVariations;
public Emoji(String... variations) {
this.variations = Arrays.asList(variations);
this(Arrays.asList(variations), Collections.emptyList());
}
public Emoji(List<String> variations) {
this(variations, Collections.emptyList());
}
public Emoji(List<String> variations, List<String> rawVariations) {
this.variations = variations;
this.rawVariations = rawVariations;
}
public String getValue() {
@@ -26,4 +35,11 @@ public class Emoji {
public boolean hasMultipleVariations() {
return variations.size() > 1;
}
public @Nullable String getRawVariation(int variationIndex) {
if (rawVariations != null && variationIndex >= 0 && variationIndex < rawVariations.size()) {
return rawVariations.get(variationIndex);
}
return null;
}
}

View File

@@ -149,8 +149,8 @@ public class EmojiProvider {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
if (jumboEmoji) {
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji());
if (jumboEmoji && drawInfo.getJumboSheet() != null) {
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
if (result instanceof JumboEmoji.LoadResult.Immediate) {
ThreadUtil.runOnMain(() -> {
jumboLoaded.set(true);
@@ -171,7 +171,11 @@ public class EmojiProvider {
@Override
public void onFailure(ExecutionException exception) {
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
Log.i(TAG, "Download restrictions are preventing jumbomoji use");
} else {
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
}
}
});
}
@@ -200,15 +204,19 @@ public class EmojiProvider {
Bitmap bitmap = null;
if (jumboEmoji) {
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji());
if (jumboEmoji && drawInfo.getJumboSheet() != null) {
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
if (result instanceof JumboEmoji.LoadResult.Immediate) {
bitmap = ((JumboEmoji.LoadResult.Immediate) result).getBitmap();
} else if (result instanceof JumboEmoji.LoadResult.Async) {
try {
bitmap = ((JumboEmoji.LoadResult.Async) result).getTask().get(10, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
Log.i(TAG, "Download restrictions are preventing jumbomoji use");
} else {
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
}
}
}

View File

@@ -13,7 +13,7 @@ import android.text.TextDirectionHeuristic;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import android.text.method.TransformationMethod;
import android.text.style.MetricAffectingSpan;
import android.text.style.CharacterStyle;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.ViewGroup;
@@ -23,24 +23,29 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import kotlin.Unit;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private static final char ELLIPSIS = '…';
private static final char ELLIPSIS = '…';
private static final float JUMBOMOJI_SCALE = 0.8f;
private boolean forceCustom;
private CharSequence previousText;
@@ -110,13 +115,13 @@ public class EmojiTextView extends AppCompatTextView {
public void setText(@Nullable CharSequence text, BufferType type) {
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
if (scaleEmojis && candidates != null && candidates.allEmojis) {
if (scaleEmojis && candidates != null && candidates.allEmojis && (candidates.hasJumboForAll() || JumboEmoji.canDownloadJumbo(getContext()))) {
int emojis = candidates.size();
float scale = 1.0f;
if (emojis <= 5) scale += 0.9f;
if (emojis <= 4) scale += 0.9f;
if (emojis <= 2) scale += 0.9f;
if (emojis <= 5) scale += JUMBOMOJI_SCALE;
if (emojis <= 4) scale += JUMBOMOJI_SCALE;
if (emojis <= 2) scale += JUMBOMOJI_SCALE;
isJumbomoji = scale > 1.0f;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
@@ -181,16 +186,16 @@ public class EmojiTextView extends AppCompatTextView {
}
/**
* Starting from API 30, there can be a rounding error in text layout when a non-default font
* scale is used. This causes a line break to be inserted where there shouldn't be one. Force the
* width to be larger to work around this problem.
* Starting from API 30, there can be a rounding error in text layout when a non-zero letter
* spacing is used. This causes a line break to be inserted where there shouldn't be one. Force
* the width to be larger to work around this problem.
* https://issuetracker.google.com/issues/173574230
*
* @param widthMeasureSpec the original measure spec passed to {@link #onMeasure(int, int)}
* @return the measure spec with the workaround, or the original one.
*/
private int applyWidthMeasureRoundingFix(int widthMeasureSpec) {
if (Build.VERSION.SDK_INT >= 30 && Math.abs(getResources().getConfiguration().fontScale - 1f) > 0.01f) {
if (Build.VERSION.SDK_INT >= 30 && getLetterSpacing() > 0) {
CharSequence text = getText();
if (text != null) {
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
@@ -213,7 +218,7 @@ public class EmojiTextView extends AppCompatTextView {
return false;
}
return ((Spanned) text).nextSpanTransition(-1, text.length(), MetricAffectingSpan.class) != text.length();
return ((Spanned) text).nextSpanTransition(-1, text.length(), CharacterStyle.class) != text.length();
}
public int getLastLineWidth() {
@@ -269,12 +274,7 @@ public class EmojiTextView extends AppCompatTextView {
}
private void ellipsizeEmojiTextForMaxLines() {
post(() -> {
if (getLayout() == null) {
ellipsizeEmojiTextForMaxLines();
return;
}
Runnable ellipsize = () -> {
int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this);
if (maxLines <= 0 && maxLength < 0) {
return;
@@ -282,10 +282,11 @@ public class EmojiTextView extends AppCompatTextView {
int lineCount = getLineCount();
if (lineCount > maxLines) {
int overflowStart = getLayout().getLineStart(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, getText().length());
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
int overflowStart = getLayout().getLineStart(maxLines - 1);
int overflowEnd = getLayout().getLineEnd(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))
@@ -297,7 +298,16 @@ public class EmojiTextView extends AppCompatTextView {
super.setText(emojified, BufferType.SPANNABLE);
}
});
};
if (getLayout() != null) {
ellipsize.run();
} else {
ViewKt.doOnPreDraw(this, view -> {
ellipsize.run();
return Unit.INSTANCE;
});
}
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {

View File

@@ -1,13 +1,5 @@
package org.thoughtcrime.securesms.components.emoji.parsing
import org.thoughtcrime.securesms.emoji.EmojiPage
import org.thoughtcrime.securesms.util.Hex
import java.nio.charset.Charset
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String) {
val rawEmoji: String
get() {
val emojiBytes: ByteArray = emoji.toByteArray(Charset.forName("UTF-16"))
return Hex.toStringCondensed(emojiBytes.slice(2 until emojiBytes.size).toByteArray())
}
}
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String, val rawEmoji: String?, val jumboSheet: String?)

View File

@@ -24,6 +24,8 @@ package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
@@ -127,6 +129,15 @@ public class EmojiParser {
return list.size();
}
public boolean hasJumboForAll() {
for (Candidate candidate : list) {
if (!JumboEmoji.hasJumboEmoji(candidate.drawInfo)) {
return false;
}
}
return true;
}
@Override
public @NonNull Iterator<Candidate> iterator() {
return list.iterator();

View File

@@ -9,7 +9,7 @@ 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.crypto.storage.SignalIdentityKeyStore;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
@@ -40,7 +40,7 @@ public class UntrustedSendDialog extends AlertDialog.Builder implements DialogIn
@Override
public void onClick(DialogInterface dialog, int which) {
final TextSecureIdentityKeyStore identityStore = ApplicationDependencies.getIdentityStore();
final SignalIdentityKeyStore identityStore = ApplicationDependencies.getProtocolStore().aci().identities();
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {

View File

@@ -43,9 +43,9 @@ public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogI
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
ApplicationDependencies.getIdentityStore().setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
}
}
return null;

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.components.menu
import android.os.Build
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Handles the setup and display of actions shown in a context menu.
*/
class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
private val mappingAdapter = MappingAdapter().apply {
registerFactory(DisplayItem::class.java, LayoutFactory({ ItemViewHolder(it, onItemClick) }, R.layout.signal_context_menu_item))
}
init {
recyclerView.apply {
adapter = mappingAdapter
layoutManager = LinearLayoutManager(context)
itemAnimator = null
}
}
fun setItems(items: List<ActionItem>) {
mappingAdapter.submitList(items.toAdapterItems())
}
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
return this.mapIndexed { index, item ->
val displayType: DisplayType = when {
this.size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE
}
DisplayItem(item, displayType)
}
}
private data class DisplayItem(
val item: ActionItem,
val displayType: DisplayType
) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
}
private enum class DisplayType {
TOP, BOTTOM, MIDDLE, ONLY
}
private class ItemViewHolder(
itemView: View,
private val onItemClick: () -> Unit,
) : MappingViewHolder<DisplayItem>(itemView) {
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
override fun bind(model: DisplayItem) {
icon.setImageResource(model.item.iconRes)
title.text = model.item.title
itemView.setOnClickListener {
model.item.action.run()
onItemClick()
}
if (Build.VERSION.SDK_INT >= 21) {
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
}
}
}
}
}

View File

@@ -6,18 +6,10 @@ import android.os.Build
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.Factory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* A custom context menu that will show next to an anchor view and display several options. Basically a PopupMenu with custom UI and positioning rules.
@@ -42,9 +34,10 @@ class SignalContextMenu private constructor(
val context: Context = anchor.context
val mappingAdapter = MappingAdapter().apply {
registerFactory(DisplayItem::class.java, ItemViewHolderFactory())
}
private val contextMenuList = ContextMenuList(
recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
onItemClick = { dismiss() },
)
init {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
@@ -59,13 +52,7 @@ class SignalContextMenu private constructor(
elevation = 20f
}
contentView.findViewById<RecyclerView>(R.id.signal_context_menu_list).apply {
adapter = mappingAdapter
layoutManager = LinearLayoutManager(context)
itemAnimator = null
}
mappingAdapter.submitList(items.toAdapterItems())
contextMenuList.setItems(items)
}
private fun show() {
@@ -97,7 +84,7 @@ class SignalContextMenu private constructor(
offsetY = baseOffsetY
} else if (menuTopBound > screenTopBound) {
offsetY = -(anchorRect.height() + contentView.measuredHeight + baseOffsetY)
mappingAdapter.submitList(items.reversed().toAdapterItems())
contextMenuList.setItems(items.reversed())
} else {
offsetY = -((anchorRect.height() / 2) + (contentView.measuredHeight / 2) + baseOffsetY)
}
@@ -122,65 +109,6 @@ class SignalContextMenu private constructor(
showAsDropDown(anchor, offsetX, offsetY)
}
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
return this.mapIndexed { index, item ->
val displayType: DisplayType = when {
this.size == 1 -> DisplayType.ONLY
index == 0 -> DisplayType.TOP
index == this.size - 1 -> DisplayType.BOTTOM
else -> DisplayType.MIDDLE
}
DisplayItem(item, displayType)
}
}
private data class DisplayItem(
val item: ActionItem,
val displayType: DisplayType
) : MappingModel<DisplayItem> {
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
return this == newItem
}
}
private enum class DisplayType {
TOP, BOTTOM, MIDDLE, ONLY
}
private inner class ItemViewHolder(itemView: View) : MappingViewHolder<DisplayItem>(itemView) {
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
override fun bind(model: DisplayItem) {
icon.setImageResource(model.item.iconRes)
title.text = model.item.title
itemView.setOnClickListener {
model.item.action.run()
dismiss()
}
if (Build.VERSION.SDK_INT >= 21) {
when (model.displayType) {
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
}
}
}
}
private inner class ItemViewHolderFactory : Factory<DisplayItem> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<DisplayItem> {
return ItemViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.signal_context_menu_item, parent, false))
}
}
enum class HorizontalPosition {
START, END
}

View File

@@ -22,7 +22,7 @@ abstract class DSLSettingsFragment(
@StringRes private val titleId: Int = -1,
@MenuRes private val menuId: Int = -1,
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment,
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
protected var layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
) : Fragment(layoutId) {
private var recyclerView: RecyclerView? = null

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings
import android.content.Context
import android.text.SpannableStringBuilder
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
@@ -81,4 +82,17 @@ sealed class DSLSettingsText {
return SpanUtil.bold(charSequence)
}
}
class LearnMoreModifier(
@ColorInt private val learnMoreColor: Int,
val onClick: () -> Unit
) : Modifier {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpannableStringBuilder(charSequence).append(" ").append(
SpanUtil.learnMore(context, learnMoreColor) {
onClick()
}
)
}
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.navigation.NavDirections
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.MainActivity
@@ -15,6 +16,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
@@ -24,6 +27,7 @@ private const val START_LOCATION = "app.settings.start.location"
private const val START_ARGUMENTS = "app.settings.start.arguments"
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_create"
class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
@@ -82,6 +86,17 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
startService(intent)
}
}
if (savedInstanceState == null) {
when (intent.getStringExtra(EXTRA_PERFORM_ACTION_ON_CREATE)) {
ACTION_CHANGE_NUMBER_SUCCESS -> {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.ChangeNumber__your_phone_number_has_changed_to_s, PhoneNumberFormatter.prettyPrint(Recipient.self().requireE164())))
.setPositiveButton(R.string.ChangeNumber__okay, null)
.show()
}
}
}
}
override fun onNewIntent(intent: Intent?) {
@@ -109,8 +124,14 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
}
companion object {
const val ACTION_CHANGE_NUMBER_SUCCESS = "action_change_number_success"
@JvmStatic
fun home(context: Context): Intent = getIntentForStartLocation(context, StartLocation.HOME)
@JvmOverloads
fun home(context: Context, action: String? = null): Intent {
return getIntentForStartLocation(context, StartLocation.HOME)
.putExtra(EXTRA_PERFORM_ACTION_ON_CREATE, action)
}
@JvmStatic
fun backups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BACKUPS)

View File

@@ -107,7 +107,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
sectionHeaderPref(R.string.AccountSettingsFragment__account)
if (FeatureFlags.changeNumber() && Recipient.self().changeNumberCapability == Recipient.Capability.SUPPORTED) {
if (FeatureFlags.changeNumber() && Recipient.self().changeNumberCapability == Recipient.Capability.SUPPORTED && SignalStore.account().isRegistered) {
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
onClick = {

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.whispersystems.signalservice.api.push.PNI
import java.util.Objects
private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)
@@ -57,7 +58,7 @@ class ChangeNumberLockActivity : PassphraseRequiredActivity() {
Single.just(false)
} else {
Log.i(TAG, "Local (${SignalStore.account().e164}) and remote (${whoAmI.number}) numbers do not match, updating local.")
changeNumberRepository.changeLocalNumber(whoAmI.number)
changeNumberRepository.changeLocalNumber(whoAmI.number, PNI.parseOrThrow(whoAmI.pni))
.map { true }
}
}

View File

@@ -12,13 +12,17 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.KbsRepository
import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException
import org.thoughtcrime.securesms.pin.TokenData
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.KbsPinData
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.io.IOException
import java.security.MessageDigest
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
@@ -61,10 +65,26 @@ class ChangeNumberRepository(private val context: Context) {
}
@WorkerThread
fun changeLocalNumber(e164: String): Single<Unit> {
fun changeLocalNumber(e164: String, pni: PNI): Single<Unit> {
val oldStorageId: ByteArray? = Recipient.self().storageServiceId
SignalDatabase.recipients.updateSelfPhone(e164)
val newStorageId: ByteArray? = Recipient.self().storageServiceId
if (MessageDigest.isEqual(oldStorageId, newStorageId)) {
Log.w(TAG, "Self storage id was not rotated, attempting to rotate again")
SignalDatabase.recipients.rotateStorageId(Recipient.self().id)
Recipient.self().live().refresh()
StorageSyncHelper.scheduleSyncForDataChange()
val secondAttemptStorageId: ByteArray? = Recipient.self().storageServiceId
if (MessageDigest.isEqual(oldStorageId, secondAttemptStorageId)) {
Log.w(TAG, "Second attempt also failed to rotate storage id")
}
}
SignalDatabase.recipients.setPni(Recipient.self().id, pni)
SignalStore.account().setE164(e164)
SignalStore.account().setPni(pni)
ApplicationDependencies.closeConnections()
ApplicationDependencies.getIncomingMessageObserver()

View File

@@ -1,15 +1,13 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.registration.fragments.CaptchaFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Helpers for various aspects of the change number flow.
@@ -36,7 +34,7 @@ object ChangeNumberUtil {
}
fun Fragment.changeNumberSuccess() {
findNavController().safeNavigate(R.id.action_pop_app_settings_change_number)
Toast.makeText(requireContext(), R.string.ChangeNumber__your_phone_number_has_been_changed, Toast.LENGTH_SHORT).show()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.home(requireContext(), AppSettingsActivity.ACTION_CHANGE_NUMBER_SUCCESS))
}
}

View File

@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.registration.VerifyProcessor
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.util.Objects
@@ -145,7 +146,7 @@ class ChangeNumberViewModel(
@WorkerThread
override fun onVerifySuccess(processor: VerifyAccountResponseProcessor): Single<VerifyAccountResponseProcessor> {
return changeNumberRepository.changeLocalNumber(number.e164Number)
return changeNumberRepository.changeLocalNumber(number.e164Number, PNI.parseOrThrow(processor.result.pni))
.map { processor }
.onErrorReturn { t ->
Log.w(TAG, "Error attempting to change local number", t)
@@ -154,7 +155,7 @@ class ChangeNumberViewModel(
}
override fun onVerifySuccessWithRegistrationLock(processor: VerifyCodeWithRegistrationLockResponseProcessor, pin: String): Single<VerifyCodeWithRegistrationLockResponseProcessor> {
return changeNumberRepository.changeLocalNumber(number.e164Number)
return changeNumberRepository.changeLocalNumber(number.e164Number, PNI.parseOrThrow(processor.result.verifyAccountResponse.pni))
.map { processor }
.onErrorReturn { t ->
Log.w(TAG, "Error attempting to change local number", t)

View File

@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
@@ -31,6 +32,7 @@ import org.thoughtcrime.securesms.payments.DataExportUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.concurrent.SimpleTask
import kotlin.math.max
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
@@ -333,9 +335,9 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
dividerPref()
if (FeatureFlags.donorBadges() && SignalStore.donationsValues().getSubscriber() != null) {
dividerPref()
sectionHeaderPref(R.string.preferences__internal_badges)
clickPref(
@@ -345,6 +347,25 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
}
dividerPref()
sectionHeaderPref(R.string.preferences__internal_release_channel)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_fetch_release_channel),
onClick = {
SignalStore.releaseChannelValues().previousManifestMd5 = ByteArray(0)
RetrieveReleaseChannelJob.enqueue(force = true)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_release_channel_set_last_version),
onClick = {
SignalStore.releaseChannelValues().highestVersionNoteReceived = max(SignalStore.releaseChannelValues().highestVersionNoteReceived - 10, 0)
}
)
}
}

View File

@@ -6,6 +6,7 @@ import android.content.Intent
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
@@ -17,6 +18,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -36,6 +38,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val MESSAGE_SOUND_SELECT: Int = 1
private const val CALL_RINGTONE_SELECT: Int = 2
private val TAG = Log.tag(NotificationsSettingsFragment::class.java)
class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__notifications) {
@@ -248,9 +251,14 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
return if (TextUtils.isEmpty(uri.toString())) {
getString(R.string.preferences__silent)
} else {
val tone = RingtoneUtil.getRingtone(requireContext(), uri)
val tone: Ringtone? = RingtoneUtil.getRingtone(requireContext(), uri)
if (tone != null) {
tone.getTitle(requireContext()) ?: getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
try {
tone.getTitle(requireContext()) ?: getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
} catch (e: SecurityException) {
Log.w(TAG, "Unable to get title for ringtone", e)
return getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
}
} else {
getString(R.string.preferences__default)
}

View File

@@ -103,13 +103,13 @@ class ExpireTimerSettingsFragment : DSLSettingsFragment(
val values: Array<Int> = resources.getIntArray(R.array.ExpireTimerSettingsFragment__values).toTypedArray()
var hasCustomValue = true
labels.zip(values).forEach { (label, value) ->
labels.zip(values).forEach { (label, seconds) ->
radioPref(
title = DSLSettingsText.from(label),
isChecked = state.currentTimer == value,
onClick = { viewModel.select(value) }
isChecked = state.currentTimer == seconds,
onClick = { viewModel.select(seconds) }
)
hasCustomValue = hasCustomValue && state.currentTimer != value
hasCustomValue = hasCustomValue && state.currentTimer != seconds
}
radioPref(

View File

@@ -8,10 +8,12 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.groups.GroupChangeException
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import java.io.IOException
private val TAG: String = Log.tag(ExpireTimerSettingsRepository::class.java)
@@ -44,6 +46,15 @@ class ExpireTimerSettingsRepository(val context: Context) {
}
}
fun setUniversalExpireTimerSeconds(newExpirationTime: Int, onDone: () -> Unit) {
SignalExecutors.BOUNDED.execute {
SignalStore.settings().universalExpireTimer = newExpirationTime
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
onDone.invoke()
}
}
@WorkerThread
private fun getThreadId(recipientId: RecipientId): Long {
val threadDatabase: ThreadDatabase = SignalDatabase.threads

View File

@@ -45,8 +45,9 @@ class ExpireTimerSettingsViewModel(val config: Config, private val repository: E
} else if (config.forResultMode) {
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
} else {
SignalStore.settings().universalExpireTimer = userSetTimer
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
repository.setUniversalExpireTimerSeconds(userSetTimer) {
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
}
}
}

View File

@@ -6,10 +6,7 @@ import org.thoughtcrime.securesms.badges.models.Badge
* Events that can arise from use of the donations apis.
*/
sealed class DonationEvent {
class GooglePayUnavailableError(val throwable: Throwable) : DonationEvent()
object RequestTokenSuccess : DonationEvent()
class RequestTokenError(val throwable: Throwable) : DonationEvent()
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
class SubscriptionCancellationFailed(val throwable: Throwable) : DonationEvent()
object SubscriptionCancelled : DonationEvent()

View File

@@ -1,7 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
class DonationExceptions {
class SetupFailed(reason: Throwable) : Exception(reason)
object TimedOutWaitingForTokenRedemption : Exception()
object RedemptionFailed : Exception()
}

View File

@@ -13,6 +13,8 @@ import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
@@ -56,8 +58,6 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
fun scheduleSyncForAccountRecordChange() {
SignalExecutors.BOUNDED.execute {
scheduleSyncForAccountRecordChangeSync()
@@ -88,13 +88,13 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
Log.d(TAG, "Creating payment intent for $price...", true)
return stripeApi.createPaymentIntent(price, application.getString(R.string.Boost__thank_you_for_your_donation))
.onErrorResumeNext { Single.error(DonationExceptions.SetupFailed(it)) }
.onErrorResumeNext { Single.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) }
.flatMapCompletable { result ->
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost amount is too small")))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost amount is too large")))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost currency is not supported")))
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(paymentData, result.paymentIntent)
}
}
@@ -141,7 +141,10 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
private fun confirmPayment(paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
Log.d(TAG, "Confirming payment intent...", true)
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent)
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it))
}
val waitOnRedemption = Completable.create {
Log.d(TAG, "Confirmed payment intent.", true)
@@ -164,20 +167,20 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Boost request response job chain failed permanently.", true)
it.onError(DonationExceptions.RedemptionFailed)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST))
}
else -> {
Log.d(TAG, "Boost request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
}
}
} else {
Log.d(TAG, "Boost redemption timed out waiting for job completion.", true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
}
} catch (e: InterruptedException) {
Log.d(TAG, "Boost redemption job interrupted", e, true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
}
}
@@ -236,20 +239,20 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
it.onError(DonationExceptions.RedemptionFailed)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION))
}
else -> {
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
}
} else {
Log.d(TAG, "Subscription request response job timed out.", true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
} catch (e: InterruptedException) {
Log.w(TAG, "Subscription request response interrupted.", e, true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
}
}
}.doOnError {

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.content.DialogInterface
import android.text.SpannableStringBuilder
import android.view.View
import androidx.appcompat.app.AlertDialog
@@ -10,6 +11,7 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.LottieAnimationView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -22,20 +24,22 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.Progress
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -63,6 +67,8 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
private lateinit var processingDonationPaymentDialog: AlertDialog
private lateinit var donationPaymentComponent: DonationPaymentComponent
private var errorDialog: DialogInterface? = null
private val sayThanks: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
.append(" ")
@@ -74,7 +80,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
donationPaymentComponent = findListener()!!
donationPaymentComponent = requireListener()
viewModel.refresh()
CurrencySelection.register(adapter)
@@ -118,10 +124,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
when (event) {
is DonationEvent.GooglePayUnavailableError -> Unit
is DonationEvent.PaymentConfirmationError -> onPaymentError(event.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
is DonationEvent.RequestTokenError -> onPaymentError(DonationExceptions.SetupFailed(event.throwable))
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
is DonationEvent.SubscriptionCancellationFailed -> Unit
@@ -130,6 +133,13 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
}
lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.BOOST)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError ->
onPaymentError(donationError)
}
}
override fun onDestroyView() {
@@ -240,37 +250,21 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
}
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Timed out while redeeming token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__still_processing)
.setMessage(R.string.DonationsErrors__your_payment_is_still)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
.show()
} else if (throwable is DonationExceptions.SetupFailed) {
Log.w(TAG, "Error occurred while processing payment", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__error_processing_payment)
.setMessage(R.string.DonationsErrors__your_payment)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
.show()
} else {
Log.w(TAG, "Error occurred while trying to redeem token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__couldnt_add_badge)
.setMessage(R.string.DonationsErrors__your_badge_could_not)
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
.show()
Log.w(TAG, "onPaymentError", throwable, true)
if (errorDialog != null) {
Log.i(TAG, "Already displaying an error dialog. Skipping.")
return
}
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
}
}
)
}
private fun startAnimationAboveSelectedBoost(view: View) {

View File

@@ -18,12 +18,14 @@ import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.thoughtcrime.securesms.util.StringUtil
import org.thoughtcrime.securesms.util.livedata.Store
import java.lang.NumberFormatException
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
@@ -108,11 +110,6 @@ class BoostViewModel(
}
)
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
)
disposables += currencyObservable.subscribeBy { currency ->
store.update {
it.copy(
@@ -146,7 +143,13 @@ class BoostViewModel(
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
onError = { throwable ->
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
onComplete = {
store.update { it.copy(stage = BoostState.Stage.READY) }
@@ -160,7 +163,7 @@ class BoostViewModel(
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.RequestTokenError(googlePayException))
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.BOOST, googlePayException))
}
override fun onCancelled() {

View File

@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.util.Locale
/**
@@ -25,7 +25,7 @@ class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
donationPaymentComponent = findListener()!!
donationPaymentComponent = requireListener()
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())

View File

@@ -0,0 +1,154 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import android.content.Context
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.logging.Log
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeError
sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : Exception(cause) {
/**
* Google Pay errors, which happen well before a user would ever be charged.
*/
sealed class GooglePayError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
class NotAvailableError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause)
class RequestTokenError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause)
}
/**
* Boost validation errors, which occur before the user could be charged.
*/
sealed class BoostError(message: String) : DonationError(DonationErrorSource.BOOST, Exception(message)) {
object AmountTooSmallError : BoostError("Amount is too small")
object AmountTooLargeError : BoostError("Amount is too large")
object InvalidCurrencyError : BoostError("Currency is not supported")
}
/**
* Stripe setup errors, which occur before the user could be charged. These are either
* payment processing handed to Stripe from the CC company (in the case of a Boost payment
* intent confirmation error) or other generic error from Stripe.
*/
sealed class PaymentSetupError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
/**
* Payment setup failed in some generic fashion.
*/
class GenericError(source: DonationErrorSource, cause: Throwable) : PaymentSetupError(source, cause)
/**
* Payment setup failed in some way, which we are told about by Stripe.
*/
class CodedError(source: DonationErrorSource, cause: Throwable, val errorCode: String) : PaymentSetupError(source, cause)
/**
* Payment failed by the credit card processor, with a specific reason told to us by Stripe.
*/
class DeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode) : PaymentSetupError(source, cause)
}
/**
* Errors that can be thrown after we submit a payment to Stripe. It is
* assumed that at this point, anything we submit *could* happen, so we can no
* longer safely assume a user has not been charged. Payment errors explicitly
* originate from Signal service.
*/
sealed class PaymentProcessingError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
class GenericError(source: DonationErrorSource) : DonationError(source, Exception("Generic Payment Error"))
}
/**
* Errors that can occur during the badge redemption process.
*/
sealed class BadgeRedemptionError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
/**
* Timeout elapsed while the user was waiting for badge redemption to complete. This is not an indication that
* redemption failed, just that it is taking longer than we can reasonably show a spinner.
*/
class TimeoutWaitingForTokenError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Timed out waiting for badge redemption to complete."))
/**
* Some generic error not otherwise accounted for occurred during the redemption process.
*/
class GenericError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Failed to add badge to account."))
}
companion object {
private val TAG = Log.tag(DonationError::class.java)
private val donationErrorSubjectSourceMap: Map<DonationErrorSource, Subject<DonationError>> = DonationErrorSource.values().associate { source ->
source to PublishSubject.create()
}
@JvmStatic
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
return donationErrorSubjectSourceMap[donationErrorSource]!!
}
/**
* Route a given donation error, which will either pipe it out to an appropriate subject
* or, if the subject has no observers, post it as a notification.
*/
@JvmStatic
fun routeDonationError(context: Context, error: DonationError) {
val subject: Subject<DonationError> = donationErrorSubjectSourceMap[error.source]!!
when {
subject.hasObservers() -> {
Log.i(TAG, "Routing donation error to subject ${error.source} dialog", error)
subject.onNext(error)
}
else -> {
Log.i(TAG, "Routing donation error to subject ${error.source} notification", error)
DonationErrorNotifications.displayErrorNotification(context, error)
}
}
}
@JvmStatic
fun getGooglePayRequestTokenError(source: DonationErrorSource, throwable: Throwable): DonationError {
return GooglePayError.RequestTokenError(source, throwable)
}
/**
* Converts a throwable into a payment setup error. This should only be used when
* handling errors handed back via the Stripe API, when we know for sure that no
* charge has occurred.
*/
@JvmStatic
fun getPaymentSetupError(source: DonationErrorSource, throwable: Throwable): DonationError {
return if (throwable is StripeError.PostError) {
val declineCode: StripeDeclineCode? = throwable.declineCode
val errorCode: String? = throwable.errorCode
when {
declineCode != null -> PaymentSetupError.DeclinedError(source, throwable, declineCode)
errorCode != null -> PaymentSetupError.CodedError(source, throwable, errorCode)
else -> PaymentSetupError.GenericError(source, throwable)
}
} else {
PaymentSetupError.GenericError(source, throwable)
}
}
@JvmStatic
fun boostAmountTooSmall(): DonationError = BoostError.AmountTooSmallError
@JvmStatic
fun boostAmountTooLarge(): DonationError = BoostError.AmountTooLargeError
@JvmStatic
fun invalidCurrencyForBoost(): DonationError = BoostError.InvalidCurrencyError
@JvmStatic
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
@JvmStatic
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
@JvmStatic
fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source)
}
}

View File

@@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import android.content.Context
import android.content.DialogInterface
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Donation Error Dialogs.
*/
object DonationErrorDialogs {
/**
* Displays a dialog, and returns a handle to it for dismissal.
*/
fun show(context: Context, throwable: Throwable?, callback: DialogCallback): DialogInterface {
val builder = MaterialAlertDialogBuilder(context)
builder.setOnDismissListener { callback.onDialogDismissed() }
val params = DonationErrorParams.create(context, throwable, callback)
if (params.title != null) {
builder.setTitle(params.title)
}
if (params.message != null) {
builder.setMessage(params.message)
}
if (params.positiveAction != null) {
builder.setPositiveButton(params.positiveAction.label) { _, _ -> params.positiveAction.action() }
}
if (params.negativeAction != null) {
builder.setNegativeButton(params.negativeAction.label) { _, _ -> params.negativeAction.action() }
}
return builder.show()
}
open class DialogCallback : DonationErrorParams.Callback<Unit> {
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = android.R.string.cancel,
action = {}
)
}
override fun onOk(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = android.R.string.ok,
action = {}
)
}
override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__go_to_google_pay,
action = {
CommunicationActions.openBrowserLink(context, context.getString(R.string.google_pay_url))
}
)
}
override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__learn_more,
action = {
CommunicationActions.openBrowserLink(context, context.getString(R.string.donation_decline_code_error_url))
}
)
}
override fun onContactSupport(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.Subscription__contact_support,
action = {
context.startActivity(AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX))
}
)
}
open fun onDialogDismissed() = Unit
}
}

View File

@@ -0,0 +1,99 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
/**
* Donation-related push notifications.
*/
object DonationErrorNotifications {
fun displayErrorNotification(context: Context, donationError: DonationError) {
val parameters = DonationErrorParams.create(context, donationError, NotificationCallback)
val notification = NotificationCompat.Builder(context, NotificationChannels.FAILURES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(parameters.title))
.setContentText(context.getString(parameters.message)).apply {
if (parameters.positiveAction != null) {
addAction(context, parameters.positiveAction)
}
if (parameters.negativeAction != null) {
addAction(context, parameters.negativeAction)
}
}
.build()
NotificationManagerCompat
.from(context)
.notify(NotificationIds.DONOR_BADGE_FAILURE, notification)
}
private fun NotificationCompat.Builder.addAction(context: Context, errorAction: DonationErrorParams.ErrorAction<PendingIntent>) {
addAction(
NotificationCompat.Action.Builder(
null,
context.getString(errorAction.label),
errorAction.action.invoke()
).build()
)
}
private object NotificationCallback : DonationErrorParams.Callback<PendingIntent> {
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
override fun onOk(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
return createAction(
context = context,
label = R.string.DeclineCode__learn_more,
actionIntent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.donation_decline_code_error_url)))
)
}
override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
return createAction(
context = context,
label = R.string.DeclineCode__go_to_google_pay,
actionIntent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.google_pay_url)))
)
}
override fun onContactSupport(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
return createAction(
context = context,
label = R.string.Subscription__contact_support,
actionIntent = AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX)
)
}
private fun createAction(
context: Context,
label: Int,
actionIntent: Intent
): DonationErrorParams.ErrorAction<PendingIntent> {
return DonationErrorParams.ErrorAction(
label = label,
action = {
PendingIntent.getActivity(
context,
0,
actionIntent,
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0
)
}
)
}
}
}

View File

@@ -0,0 +1,97 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import android.content.Context
import androidx.annotation.StringRes
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.R
class DonationErrorParams<V> private constructor(
@StringRes val title: Int,
@StringRes val message: Int,
val positiveAction: ErrorAction<V>?,
val negativeAction: ErrorAction<V>?
) {
class ErrorAction<V>(
@StringRes val label: Int,
val action: () -> V
)
companion object {
fun <V> create(
context: Context,
throwable: Throwable?,
callback: Callback<V>
): DonationErrorParams<V> {
return when (throwable) {
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError -> DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = R.string.DonationsErrors__your_payment,
positiveAction = callback.onOk(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> DonationErrorParams(
title = R.string.DonationsErrors__still_processing,
message = R.string.DonationsErrors__your_payment_is_still,
positiveAction = callback.onOk(context),
negativeAction = null
)
else -> DonationErrorParams(
title = R.string.DonationsErrors__couldnt_add_badge,
message = R.string.DonationsErrors__your_badge_could_not,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
}
}
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
return when (declinedError.declineCode) {
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again)
StripeDeclineCode.Code.CALL_ISSUER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem)
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase)
StripeDeclineCode.Code.EXPIRED_CARD -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_has_expired)
StripeDeclineCode.Code.INCORRECT_NUMBER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_number_is_incorrect)
StripeDeclineCode.Code.INCORRECT_CVC -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_cards_cvc_number_is_incorrect)
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_have_sufficient_funds)
StripeDeclineCode.Code.INVALID_CVC -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_cards_cvc_number_is_incorrect)
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__the_expiration_month)
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__the_expiration_year)
StripeDeclineCode.Code.INVALID_NUMBER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_number_is_incorrect)
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_completing_the_payment_again)
StripeDeclineCode.Code.PROCESSING_ERROR -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again)
StripeDeclineCode.Code.REENTER_TRANSACTION -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again)
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
}
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
}
}
private fun <V> getLearnMoreParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = message,
positiveAction = callback.onOk(context),
negativeAction = callback.onLearnMore(context)
)
}
private fun <V> getGoToGooglePayParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = message,
positiveAction = callback.onGoToGooglePay(context),
negativeAction = callback.onCancel(context)
)
}
}
interface Callback<V> {
fun onOk(context: Context): ErrorAction<V>?
fun onCancel(context: Context): ErrorAction<V>?
fun onLearnMore(context: Context): ErrorAction<V>?
fun onContactSupport(context: Context): ErrorAction<V>?
fun onGoToGooglePay(context: Context): ErrorAction<V>?
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
enum class DonationErrorSource(private val code: String) {
BOOST("boost"),
SUBSCRIPTION("subscription"),
KEEP_ALIVE("keep-alive"),
UNKNOWN("unknown");
fun serialize(): String = code
companion object {
@JvmStatic
fun deserialize(code: String): DonationErrorSource {
return values().firstOrNull { it.code == code } ?: UNKNOWN
}
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
/**
* Error states that can occur if we detect that a user's subscription has been cancelled and the manual
* cancellation flag is not set.
*/
enum class UnexpectedSubscriptionCancellation(val status: String) {
PAST_DUE("past_due"),
CANCELED("canceled"),
UNPAID("unpaid"),
INACTIVE("user-was-inactive");
companion object {
@JvmStatic
fun fromStatus(status: String?): UnexpectedSubscriptionCancellation? {
return values().firstOrNull { it.status == status }
}
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.content.DialogInterface
import android.graphics.Color
import android.text.SpannableStringBuilder
import androidx.appcompat.app.AlertDialog
@@ -8,6 +9,7 @@ import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
@@ -20,22 +22,22 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.Progress
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.Calendar
import java.util.Currency
@@ -63,6 +65,8 @@ class SubscribeFragment : DSLSettingsFragment(
private lateinit var processingDonationPaymentDialog: AlertDialog
private lateinit var donationPaymentComponent: DonationPaymentComponent
private var errorDialog: DialogInterface? = null
private val viewModel: SubscribeViewModel by viewModels(
factoryProducer = {
SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationPaymentComponent.donationPaymentRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
@@ -75,7 +79,7 @@ class SubscribeFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
donationPaymentComponent = findListener()!!
donationPaymentComponent = requireListener()
viewModel.refresh()
BadgePreview.register(adapter)
@@ -97,10 +101,7 @@ class SubscribeFragment : DSLSettingsFragment(
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe {
when (it) {
is DonationEvent.GooglePayUnavailableError -> Unit
is DonationEvent.PaymentConfirmationError -> onPaymentError(it.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
is DonationEvent.RequestTokenError -> onPaymentError(DonationExceptions.SetupFailed(it.throwable))
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable)
@@ -109,6 +110,13 @@ class SubscribeFragment : DSLSettingsFragment(
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
}
lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.SUBSCRIPTION)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError ->
onPaymentError(donationError)
}
}
override fun onDestroyView() {
@@ -277,49 +285,21 @@ class SubscribeFragment : DSLSettingsFragment(
}
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Timeout occurred while redeeming token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__still_processing)
.setMessage(R.string.DonationsErrors__your_payment_is_still)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
}
.show()
} else if (throwable is DonationExceptions.SetupFailed) {
Log.w(TAG, "Error occurred while processing payment", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__error_processing_payment)
.setMessage(R.string.DonationsErrors__your_payment)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
}
.show()
} else if (SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
Log.w(TAG, "Stripe failed to process payment", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__error_processing_payment)
.setMessage(R.string.DonationsErrors__your_badge_could_not_be_added)
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
}
.show()
} else {
Log.w(TAG, "Error occurred while trying to redeem token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__couldnt_add_badge)
.setMessage(R.string.DonationsErrors__your_badge_could_not)
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
}
.show()
Log.w(TAG, "onPaymentError", throwable, true)
if (errorDialog != null) {
Log.i(TAG, "Already displaying an error dialog. Skipping.")
return
}
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
}
}
)
}
private fun onSubscriptionCancelled() {

View File

@@ -18,9 +18,11 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.LevelUpdate
@@ -129,11 +131,6 @@ class SubscribeViewModel(
onError = this::handleSubscriptionDataLoadFailure
)
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
)
disposables += currency.subscribe { selection ->
store.update { it.copy(currencySelection = selection) }
}
@@ -170,6 +167,7 @@ class SubscribeViewModel(
SignalStore.donationsValues().setLastEndOfPeriod(0L)
SignalStore.donationsValues().clearLevelOperations()
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = false
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
MultiDeviceSubscriptionSyncRequestJob.enqueue()
}
} else {
@@ -186,6 +184,7 @@ class SubscribeViewModel(
SignalStore.donationsValues().setLastEndOfPeriod(0L)
SignalStore.donationsValues().clearLevelOperations()
SignalStore.donationsValues().markUserManuallyCancelled()
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
refreshActiveSubscription()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
donationPaymentRepository.scheduleSyncForAccountRecordChange()
@@ -222,13 +221,20 @@ class SubscribeViewModel(
val setup = ensureSubscriberId
.andThen(cancelActiveSubscriptionIfNecessary())
.andThen(continueSetup)
.onErrorResumeNext { Completable.error(DonationExceptions.SetupFailed(it)) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
setup.andThen(setLevel).subscribeBy(
onError = { throwable ->
refreshActiveSubscription()
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
onComplete = {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
@@ -242,7 +248,7 @@ class SubscribeViewModel(
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.RequestTokenError(googlePayException))
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.SUBSCRIPTION, googlePayException))
}
override fun onCancelled() {
@@ -262,7 +268,13 @@ class SubscribeViewModel(
},
onError = { throwable ->
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
}
)
}

View File

@@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import app.cash.exhaustive.Exhaustive
import com.google.android.flexbox.FlexboxLayoutManager
@@ -92,8 +93,7 @@ private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
class ConversationSettingsFragment : DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings,
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
menuId = R.menu.conversation_settings
) {
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
@@ -151,6 +151,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
toolbarTitle = view.findViewById(R.id.toolbar_title)
toolbarBackground = view.findViewById(R.id.toolbar_background)
val args: ConversationSettingsFragmentArgs = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
if (args.recipientId != null) {
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
}
super.onViewCreated(view, savedInstanceState)
}
@@ -393,28 +398,32 @@ class ConversationSettingsFragment : DSLSettingsFragment(
enabled = it.canEditGroupAttributes
}
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages),
summary = summary,
icon = DSLSettingsIcon.from(icon),
isEnabled = enabled,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer()
.setInitialValue(state.disappearingMessagesLifespan)
.setRecipientId(state.recipient.id)
.setForResultMode(false)
if (!state.recipient.isReleaseNotes) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages),
summary = summary,
icon = DSLSettingsIcon.from(icon),
isEnabled = enabled,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer()
.setInitialValue(state.disappearingMessagesLifespan)
.setRecipientId(state.recipient.id)
.setForResultMode(false)
navController.safeNavigate(action)
}
)
navController.safeNavigate(action)
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
icon = DSLSettingsIcon.from(R.drawable.ic_color_24),
onClick = {
startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id))
}
)
if (!state.recipient.isReleaseNotes) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
icon = DSLSettingsIcon.from(R.drawable.ic_color_24),
onClick = {
startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id))
}
)
}
if (!state.recipient.isSelf) {
clickPref(
@@ -507,7 +516,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
}
if (recipientSettingsState.selfHasGroups) {
if (recipientSettingsState.selfHasGroups && !state.recipient.isReleaseNotes) {
dividerPref()
@@ -758,9 +767,14 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private val rect = Rect()
override fun getAnimationState(recyclerView: RecyclerView): AnimationState {
val layoutManager = recyclerView.layoutManager as FlexboxLayoutManager
val layoutManager = recyclerView.layoutManager!!
val firstVisibleItemPosition = if (layoutManager is FlexboxLayoutManager) {
layoutManager.findFirstVisibleItemPosition()
} else {
(layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
}
return if (layoutManager.findFirstVisibleItemPosition() == 0) {
return if (firstVisibleItemPosition == 0) {
val firstChild = requireNotNull(layoutManager.getChildAt(0))
firstChild.getLocalVisibleRect(rect)

View File

@@ -65,7 +65,7 @@ class ConversationSettingsRepository(
fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(ApplicationDependencies.getIdentityStore().getIdentityRecord(recipientId).orNull())
consumer(ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipientId).orNull())
}
}

View File

@@ -130,8 +130,8 @@ sealed class ConversationSettingsViewModel(
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf,
isAudioAvailable = !recipient.isGroup && !recipient.isSelf,
isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
isAudioAvailable = !recipient.isGroup && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
isAudioSecure = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED,
isMuted = recipient.isMuted,
isMuteAvailable = !recipient.isSelf,
@@ -141,7 +141,7 @@ sealed class ConversationSettingsViewModel(
canModifyBlockedState = !recipient.isSelf && RecipientUtil.isBlockable(recipient),
specificSettingsState = state.requireRecipientSettingsState().copy(
contactLinkState = when {
recipient.isSelf -> ContactLinkState.NONE
recipient.isSelf || recipient.isReleaseNotes -> ContactLinkState.NONE
recipient.isSystemContact -> ContactLinkState.OPEN
else -> ContactLinkState.ADD
}
@@ -240,11 +240,12 @@ sealed class ConversationSettingsViewModel(
private val liveGroup = LiveGroup(groupId)
init {
store.update(liveGroup.groupRecipient) { recipient, state ->
val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a }
store.update(recipientAndIsActive) { (recipient, isActive), state ->
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isVideoAvailable = recipient.isPushV2Group,
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive,
isAudioAvailable = false,
isAudioSecure = recipient.isPushV2Group,
isMuted = recipient.isMuted,

View File

@@ -26,8 +26,7 @@ import org.thoughtcrime.securesms.util.Hex
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.Objects
/**
@@ -61,18 +60,11 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
)
if (!recipient.isGroup) {
val aci = recipient.aci.transform(ACI::toString).or("null")
val serviceId = recipient.serviceId.transform(ServiceId::toString).or("null")
longClickPref(
title = DSLSettingsText.from("ACI"),
summary = DSLSettingsText.from(aci),
onLongClick = { copyToClipboard(aci) }
)
val pni = recipient.pni.transform(PNI::toString).or("null")
longClickPref(
title = DSLSettingsText.from("PNI"),
summary = DSLSettingsText.from(pni),
onLongClick = { copyToClipboard(pni) }
title = DSLSettingsText.from("ServiceId"),
summary = DSLSettingsText.from(serviceId),
onLongClick = { copyToClipboard(serviceId) }
)
}
@@ -153,11 +145,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
if (recipient.hasAci()) {
SignalDatabase.sessions.deleteAllFor(recipient.requireAci().toString())
}
if (recipient.hasE164()) {
SignalDatabase.sessions.deleteAllFor(recipient.requireE164())
if (recipient.hasServiceId()) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireServiceId().toString())
}
}
.show()

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.content.ClipData
import android.content.Context
import android.text.SpannableStringBuilder
import android.view.View
import android.widget.TextView
import android.widget.Toast
@@ -9,7 +10,9 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
@@ -25,8 +28,8 @@ object BioTextPreference {
}
abstract class BioTextPreferenceModel<T : BioTextPreferenceModel<T>> : PreferenceModel<T>() {
abstract fun getHeadlineText(context: Context): String
abstract fun getSubhead1Text(): String?
abstract fun getHeadlineText(context: Context): CharSequence
abstract fun getSubhead1Text(context: Context): String?
abstract fun getSubhead2Text(): String?
}
@@ -34,9 +37,24 @@ object BioTextPreference {
private val recipient: Recipient,
) : BioTextPreferenceModel<RecipientModel>() {
override fun getHeadlineText(context: Context): String = recipient.getDisplayNameOrUsername(context)
override fun getHeadlineText(context: Context): CharSequence {
val name = recipient.getDisplayNameOrUsername(context)
return if (recipient.isReleaseNotes) {
SpannableStringBuilder(name).apply {
SpanUtil.appendCenteredImageSpan(this, ContextUtil.requireDrawable(context, R.drawable.ic_official_28), 28, 28)
}
} else {
name
}
}
override fun getSubhead1Text(): String? = recipient.combinedAboutAndEmoji
override fun getSubhead1Text(context: Context): String? {
return if (recipient.isReleaseNotes) {
context.getString(R.string.ReleaseNotes__signal_release_notes_and_news)
} else {
recipient.combinedAboutAndEmoji
}
}
override fun getSubhead2Text(): String? = recipient.e164.transform(PhoneNumberFormatter::prettyPrint).orNull()
@@ -53,9 +71,9 @@ object BioTextPreference {
val groupTitle: String,
val groupMembershipDescription: String?
) : BioTextPreferenceModel<GroupModel>() {
override fun getHeadlineText(context: Context): String = groupTitle
override fun getHeadlineText(context: Context): CharSequence = groupTitle
override fun getSubhead1Text(): String? = groupMembershipDescription
override fun getSubhead1Text(context: Context): String? = groupMembershipDescription
override fun getSubhead2Text(): String? = null
@@ -79,7 +97,7 @@ object BioTextPreference {
override fun bind(model: T) {
headline.text = model.getHeadlineText(context)
model.getSubhead1Text().let {
model.getSubhead1Text(context).let {
subhead1.text = it
subhead1.visibility = if (it == null) View.GONE else View.VISIBLE
}

View File

@@ -20,7 +20,6 @@ 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
import java.lang.NullPointerException
private val TAG = Log.tag(CustomNotificationsSettingsFragment::class.java)
@@ -149,6 +148,9 @@ class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomN
} catch (e: NullPointerException) {
Log.w(TAG, "Could not get correct title for ringtone.", e)
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
} catch (e: SecurityException) {
Log.w(TAG, "Could not get correct title for ringtone.", e)
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import android.widget.ImageView
import androidx.annotation.DrawableRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Renders a single image, horizontally centered.
*/
object SplashImage {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.splash_image))
}
class Model(@DrawableRes val splashImageResId: Int) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.splashImageResId == splashImageResId
}
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val splashImageView: ImageView = itemView as ImageView
override fun bind(model: Model) {
splashImageView.setImageResource(model.splashImageResId)
}
}
}

View File

@@ -16,6 +16,7 @@ import android.support.v4.media.session.PlaybackStateCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
@@ -50,7 +51,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
private MediaBrowserCompat mediaBrowser;
private AppCompatActivity activity;
private FragmentActivity activity;
private ProgressEventHandler progressEventHandler;
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
@@ -58,7 +59,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
public VoiceNoteMediaController(@NonNull AppCompatActivity activity) {
public VoiceNoteMediaController(@NonNull FragmentActivity activity) {
this.activity = activity;
this.mediaBrowser = new MediaBrowserCompat(activity,
new ComponentName(activity, VoiceNotePlaybackService.class),
@@ -190,6 +191,27 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
}
}
/**
* Tells the Media service to resume playback of a given audio slide. If the audio slide is not
* currently paused, playback will be started from the beginning.
*
* @param audioSlideUri The Uri of the desired audio slide
* @param messageId The Message id of the given audio slide
*/
public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) {
if (isCurrentTrack(audioSlideUri)) {
getMediaController().getTransportControls().play();
} else {
Bundle extras = new Bundle();
extras.putLong(EXTRA_MESSAGE_ID, messageId);
extras.putLong(EXTRA_THREAD_ID, -1L);
extras.putDouble(EXTRA_PROGRESS, 0.0);
extras.putBoolean(EXTRA_PLAY_SINGLE, true);
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
}
}
/**
* Pauses playback if the given audio slide is playing.
*

View File

@@ -10,7 +10,7 @@ import android.os.Bundle
import android.os.PowerManager
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
@@ -25,7 +25,7 @@ private const val PROXIMITY_THRESHOLD = 5f
* Manages the WakeLock while a VoiceNote is playing back in the target activity.
*/
class VoiceNoteProximityWakeLockManager(
private val activity: AppCompatActivity,
private val activity: FragmentActivity,
private val mediaController: MediaControllerCompat
) : DefaultLifecycleObserver {

View File

@@ -127,10 +127,10 @@ data class CallParticipantsState(
fun getIncomingRingingGroupDescription(context: Context): String? {
if (callState == WebRtcViewModel.State.CALL_INCOMING &&
groupCallState == WebRtcViewModel.GroupCallState.RINGING &&
ringerRecipient.hasAci()
ringerRecipient.hasServiceId()
) {
val ringerName = ringerRecipient.getShortDisplayName(context)
val membersWithoutYouOrRinger: List<GroupMemberEntry.FullMember> = groupMembers.filterNot { it.member.isSelf || ringerRecipient.requireAci() == it.member.aci.orNull() }
val membersWithoutYouOrRinger: List<GroupMemberEntry.FullMember> = groupMembers.filterNot { it.member.isSelf || ringerRecipient.requireServiceId() == it.member.serviceId.orNull() }
return when (membersWithoutYouOrRinger.size) {
0 -> context.getString(R.string.WebRtcCallView__s_is_calling_you, ringerName)

View File

@@ -35,7 +35,7 @@ class WebRtcCallRepository {
recipients = Collections.singletonList(recipient);
}
consumer.accept(ApplicationDependencies.getIdentityStore().getIdentityRecords(recipients));
consumer.accept(ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients));
});
}
}

View File

@@ -96,8 +96,8 @@ public class WebRtcCallView extends ConstraintLayout {
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private ImageView hangup;
private TextView hangupLabel;
private View answerWithAudio;
private View answerWithAudioLabel;
private View answerWithoutVideo;
private View answerWithoutVideoLabel;
private View topGradient;
private View footerGradient;
private View startCallControls;
@@ -178,8 +178,8 @@ public class WebRtcCallView extends ConstraintLayout {
ringToggleLabel = findViewById(R.id.call_screen_audio_ring_toggle_label);
hangup = findViewById(R.id.call_screen_end_call);
hangupLabel = findViewById(R.id.call_screen_end_call_label);
answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label);
answerWithoutVideo = findViewById(R.id.call_screen_answer_without_video);
answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label);
topGradient = findViewById(R.id.call_screen_header_gradient);
footerGradient = findViewById(R.id.call_screen_footer_gradient);
startCallControls = findViewById(R.id.call_screen_start_call_controls);
@@ -255,7 +255,7 @@ public class WebRtcCallView extends ConstraintLayout {
decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed));
answer.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed));
answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame);
pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper();
@@ -286,7 +286,7 @@ public class WebRtcCallView extends ConstraintLayout {
rotatableControls.add(hangup);
rotatableControls.add(answer);
rotatableControls.add(answerWithAudio);
rotatableControls.add(answerWithoutVideo);
rotatableControls.add(audioToggle);
rotatableControls.add(micToggle);
rotatableControls.add(videoToggle);
@@ -590,19 +590,19 @@ public class WebRtcCallView extends ConstraintLayout {
if (webRtcControls.displayIncomingCallButtons()) {
visibleViewSet.addAll(incomingCallViews);
incomingRingStatus.setText(webRtcControls.displayAnswerWithAudio() ? R.string.WebRtcCallView__signal_call : R.string.WebRtcCallView__signal_video_call);
incomingRingStatus.setText(webRtcControls.displayAnswerWithoutVideo() ? R.string.WebRtcCallView__signal_video_call: R.string.WebRtcCallView__signal_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
}
if (webRtcControls.displayAnswerWithAudio()) {
visibleViewSet.add(answerWithAudio);
visibleViewSet.add(answerWithAudioLabel);
if (webRtcControls.displayAnswerWithoutVideo()) {
visibleViewSet.add(answerWithoutVideo);
visibleViewSet.add(answerWithoutVideoLabel);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
}
if (!webRtcControls.displayIncomingCallButtons() && !webRtcControls.displayAnswerWithAudio()){
if (!webRtcControls.displayIncomingCallButtons()){
incomingRingStatus.setVisibility(GONE);
}

View File

@@ -165,7 +165,7 @@ public final class WebRtcControls {
return isOngoing();
}
boolean displayAnswerWithAudio() {
boolean displayAnswerWithoutVideo() {
return isIncoming() && isRemoteVideoEnabled;
}

View File

@@ -10,7 +10,6 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
@@ -101,9 +100,9 @@ public class ContactRepository {
}));
}};
public ContactRepository(@NonNull Context context) {
public ContactRepository(@NonNull Context context, @NonNull String noteToSelfTitle) {
this.recipientDatabase = SignalDatabase.recipients();
this.noteToSelfTitle = context.getString(R.string.note_to_self);
this.noteToSelfTitle = noteToSelfTitle;
this.context = context.getApplicationContext();
}

View File

@@ -75,7 +75,7 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
this.mode = mode;
this.recents = recents;
this.contactRepository = new ContactRepository(context);
this.contactRepository = new ContactRepository(context, context.getString(R.string.note_to_self));
}
protected final List<Cursor> getUnfilteredResults() {

View File

@@ -65,10 +65,6 @@ public class ProfileContactPhoto implements ContactPhoto {
}
private long getFileLastModified() {
if (!recipient.isSelf()) {
return 0;
}
return AvatarHelper.getLastModified(ApplicationDependencies.getApplication(), recipient.getId());
}
}

View File

@@ -23,12 +23,12 @@ import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.contacts.ContactsDatabase;
import org.thoughtcrime.securesms.crypto.SessionUtil;
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.signalservice.api.push.ACI;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
@@ -54,6 +54,7 @@ import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.ServiceResponse;
@@ -111,9 +112,9 @@ public class DirectoryHelper {
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
for (Recipient recipient : recipients) {
if (recipient.hasAci() && !recipient.hasE164()) {
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireAci())) {
recipientDatabase.markRegistered(recipient.getId(), recipient.requireAci());
if (recipient.hasServiceId() && !recipient.hasE164()) {
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireServiceId())) {
recipientDatabase.markRegistered(recipient.getId(), recipient.requireServiceId());
} else {
recipientDatabase.markUnregistered(recipient.getId());
}
@@ -135,11 +136,11 @@ public class DirectoryHelper {
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
RegisteredState newRegisteredState;
if (recipient.hasAci() && !recipient.hasE164()) {
boolean isRegistered = ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireAci());
if (recipient.hasServiceId() && !recipient.hasE164()) {
boolean isRegistered = ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireServiceId());
stopwatch.split("aci-network");
if (isRegistered) {
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), recipient.requireAci());
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), recipient.requireServiceId());
if (idChanged) {
Log.w(TAG, "ID changed during refresh by UUID.");
}
@@ -172,14 +173,14 @@ public class DirectoryHelper {
if (aci != null) {
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), aci);
if (idChanged) {
recipient = Recipient.resolved(recipientDatabase.getByAci(aci).get());
recipient = Recipient.resolved(recipientDatabase.getByServiceId(aci).get());
}
} else {
Log.w(TAG, "Registered number set had a null ACI!");
}
} else if (recipient.hasAci() && recipient.isRegistered() && hasCommunicatedWith(context, recipient)) {
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireAci())) {
recipientDatabase.markRegistered(recipient.getId(), recipient.requireAci());
} else if (recipient.hasServiceId() && recipient.isRegistered() && hasCommunicatedWith(recipient)) {
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireServiceId())) {
recipientDatabase.markRegistered(recipient.getId(), recipient.requireServiceId());
} else {
recipientDatabase.markUnregistered(recipient.getId());
}
@@ -464,9 +465,9 @@ public class DirectoryHelper {
for (RecipientId newUser: newUsers) {
Recipient recipient = Recipient.resolved(newUser);
if (!SessionUtil.hasSession(recipient.getId()) &&
!recipient.isSelf() &&
recipient.hasAUserSetDisplayName(context))
if (!recipient.isSelf() &&
recipient.hasAUserSetDisplayName(context) &&
!hasSession(recipient.getId()))
{
IncomingJoinedMessage message = new IncomingJoinedMessage(recipient.getId());
Optional<InsertResult> insertResult = SignalDatabase.sms().insertMessageInbox(message);
@@ -483,6 +484,19 @@ public class DirectoryHelper {
}
}
public static boolean hasSession(@NonNull RecipientId id) {
Recipient recipient = Recipient.resolved(id);
if (!recipient.hasServiceId()) {
return false;
}
SignalProtocolAddress protocolAddress = Recipient.resolved(id).requireServiceId().toProtocolAddress(SignalServiceAddress.DEFAULT_DEVICE_ID);
return ApplicationDependencies.getProtocolStore().aci().containsSession(protocolAddress) ||
ApplicationDependencies.getProtocolStore().pni().containsSession(protocolAddress);
}
private static Set<String> sanitizeNumbers(@NonNull Set<String> numbers) {
return Stream.of(numbers).filter(number -> {
try {
@@ -503,8 +517,8 @@ public class DirectoryHelper {
List<Recipient> possiblyUnlisted = Stream.of(inactiveIds)
.map(Recipient::resolved)
.filter(Recipient::isRegistered)
.filter(Recipient::hasAci)
.filter(r -> hasCommunicatedWith(context, r))
.filter(Recipient::hasServiceId)
.filter(DirectoryHelper::hasCommunicatedWith)
.toList();
ProfileService profileService = new ProfileService(ApplicationDependencies.getGroupsV2Operations().getProfileOperations(),
@@ -537,10 +551,10 @@ public class DirectoryHelper {
.blockingGet();
}
private static boolean hasCommunicatedWith(@NonNull Context context, @NonNull Recipient recipient) {
return SignalDatabase.threads().hasThread(recipient.getId()) ||
(recipient.hasAci() && SignalDatabase.sessions().hasSessionFor(recipient.requireAci().toString())) ||
(recipient.hasE164() && SignalDatabase.sessions().hasSessionFor(recipient.requireE164()));
private static boolean hasCommunicatedWith(@NonNull Recipient recipient) {
ACI localAci = SignalStore.account().requireAci();
return SignalDatabase.threads().hasThread(recipient.getId()) || (recipient.hasServiceId() && SignalDatabase.sessions().hasSessionFor(localAci, recipient.requireServiceId().toString()));
}
static class DirectoryResult {

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.conversation;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
/**
* Activity which encapsulates a conversation for a Bubble window.
*
@@ -9,7 +12,11 @@ package org.thoughtcrime.securesms.conversation;
*/
public class BubbleConversationActivity extends ConversationActivity {
@Override
protected boolean isInBubble() {
public boolean isInBubble() {
return true;
}
@Override
public void onInitializeToolbar(@NonNull Toolbar toolbar) {
}
}

View File

@@ -0,0 +1,83 @@
package org.thoughtcrime.securesms.conversation
import android.content.Intent
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.HidingLinearLayout
import org.thoughtcrime.securesms.components.reminder.ReminderView
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture
import org.thoughtcrime.securesms.util.views.Stub
open class ConversationActivity : PassphraseRequiredActivity(), ConversationParentFragment.Callback, DonationPaymentComponent {
private lateinit var fragment: ConversationParentFragment
private val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
override fun onPreCreate() {
dynamicTheme.onCreate(this)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContentView(R.layout.conversation_parent_fragment_container)
fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as ConversationParentFragment
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
fragment.onNewIntent(intent)
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return fragment.dispatchTouchEvent(ev) || super.dispatchTouchEvent(ev)
}
override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
}
override fun onInitializeToolbar(toolbar: Toolbar) {
toolbar.navigationIcon = AppCompatResources.getDrawable(this, R.drawable.ic_arrow_left_24)
toolbar.setNavigationOnClickListener { finish() }
}
fun saveDraft(): ListenableFuture<Long> {
return fragment.saveDraft()
}
fun getRecipient(): Recipient {
return fragment.recipient
}
fun getTitleView(): View {
return fragment.titleView
}
fun getComposeText(): View {
return fragment.composeText
}
fun getQuickAttachmentToggle(): HidingLinearLayout {
return fragment.quickAttachmentToggle
}
fun getReminderView(): Stub<ReminderView> {
return fragment.reminderView
}
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
}

View File

@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms.conversation;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Build;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -39,6 +40,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.MediaItem;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagingController;
@@ -401,7 +403,9 @@ public class ConversationAdapter
}
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
int messagePosition = isTypingViewEnabled ? position - 1 : position;
int count = messagePosition + 1;
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, count, count));
if (hasWallpaper) {
viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_8);
@@ -704,6 +708,11 @@ public class ConversationAdapter
return getBindable().canPlayContent();
}
@Override
public boolean shouldProjectContent() {
return getBindable().shouldProjectContent();
}
@Override
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
return getBindable().getColorizerProjections(coordinateRoot);
@@ -778,7 +787,23 @@ public class ConversationAdapter
}
public static class FooterViewHolder extends HeaderFooterViewHolder {
FooterViewHolder(@NonNull View itemView) { super(itemView); }
FooterViewHolder(@NonNull View itemView) {
super(itemView);
setPaddingTop();
}
@Override
void bind(@Nullable View view) {
super.bind(view);
setPaddingTop();
}
private void setPaddingTop() {
if (Build.VERSION.SDK_INT <= 23) {
int addToPadding = ViewUtil.getStatusBarHeight(itemView) + (int) ThemeUtil.getThemedDimen(itemView.getContext(), android.R.attr.actionBarSize);
ViewUtil.setPaddingTop(itemView, itemView.getPaddingTop() + addToPadding);
}
}
}
public static class HeaderViewHolder extends HeaderFooterViewHolder {

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -20,7 +21,9 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.SpanUtil;
public class ConversationBannerView extends ConstraintLayout {
@@ -78,11 +81,23 @@ public class ConversationBannerView extends ConstraintLayout {
}
}
public void setTitle(@Nullable CharSequence title) {
public String setTitle(@NonNull Recipient recipient) {
SpannableStringBuilder title = new SpannableStringBuilder(recipient.isSelf() ? getContext().getString(R.string.note_to_self) : recipient.getDisplayNameOrUsername(getContext()));
if (recipient.isReleaseNotes()) {
SpanUtil.appendCenteredImageSpan(title, ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_28), 28, 28);
}
contactTitle.setText(title);
return title.toString();
}
public void setAbout(@Nullable String about) {
public void setAbout(@NonNull Recipient recipient) {
String about;
if (recipient.isReleaseNotes()) {
about = getContext().getString(R.string.ReleaseNotes__signal_release_notes_and_news);
} else {
about = recipient.getCombinedAboutAndEmoji();
}
contactAbout.setText(about);
contactAbout.setVisibility(TextUtils.isEmpty(about) ? GONE : VISIBLE);
}

View File

@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import android.os.Build
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.ContextMenuList
/**
* The context menu shown after long pressing a message in ConversationActivity.
*/
class ConversationContextMenu(private val anchor: View, items: List<ActionItem>) : PopupWindow(
LayoutInflater.from(anchor.context).inflate(R.layout.signal_context_menu, null),
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
) {
val context: Context = anchor.context
private val contextMenuList = ContextMenuList(
recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
onItemClick = { dismiss() },
)
init {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
animationStyle = R.style.ConversationContextMenuAnimation
isFocusable = false
isOutsideTouchable = true
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
setTouchInterceptor { _, event ->
event.action == MotionEvent.ACTION_OUTSIDE
}
contextMenuList.setItems(items)
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
}
fun getMaxWidth(): Int = contentView.measuredWidth
fun getMaxHeight(): Int = contentView.measuredHeight
fun show(offsetX: Int, offsetY: Int) {
showAsDropDown(anchor, offsetX, offsetY, Gravity.TOP or Gravity.START)
}
}

View File

@@ -21,10 +21,10 @@ import android.animation.Animator;
import android.animation.LayoutTransition;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Rect;
import android.net.Uri;
@@ -58,7 +58,8 @@ import androidx.core.view.ViewCompat;
import androidx.core.view.ViewKt;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProviders;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
@@ -72,7 +73,6 @@ import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ConversationScrollToView;
import org.thoughtcrime.securesms.components.ConversationTypingView;
@@ -122,8 +122,8 @@ import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.longmessage.LongMessageActivity;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity;
import org.thoughtcrime.securesms.longmessage.LongMessageFragment;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment;
import org.thoughtcrime.securesms.messagerequests.MessageRequestState;
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel;
import org.thoughtcrime.securesms.mms.GlideApp;
@@ -150,6 +150,7 @@ import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.HtmlUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
@@ -182,7 +183,6 @@ import java.util.Set;
import java.util.concurrent.ExecutionException;
import kotlin.Unit;
import kotlin.jvm.functions.Function1;
@SuppressLint("StaticFieldLeak")
public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback {
@@ -198,7 +198,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private LiveRecipient recipient;
private long threadId;
private boolean isReacting;
private ActionMode actionMode;
private Locale locale;
private FrameLayout videoContainer;
@@ -253,7 +252,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
this.locale = (Locale) getArguments().getSerializable(PassphraseRequiredActivity.LOCALE_EXTRA);
this.locale = Locale.getDefault();
startupStopwatch = new Stopwatch("conversation-open");
SignalLocalMetrics.ConversationOpen.start();
}
@@ -323,9 +322,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
giphyMp4ProjectionRecycler = initializeGiphyMp4();
this.groupViewModel = ViewModelProviders.of(requireActivity(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
this.messageCountsViewModel = ViewModelProviders.of(requireActivity()).get(MessageCountsViewModel.class);
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
this.groupViewModel = new ViewModelProvider(getParentFragment(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
this.messageCountsViewModel = new ViewModelProvider(getParentFragment()).get(MessageCountsViewModel.class);
this.conversationViewModel = new ViewModelProvider(getParentFragment(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), recyclerViewColorizer::setChatColors);
conversationViewModel.getMessages().observe(getViewLifecycleOwner(), messages -> {
@@ -381,6 +380,13 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
conversationViewModel.getActiveNotificationProfile().observe(getViewLifecycleOwner(), this::updateNotificationProfileStatus);
initializeScrollButtonAnimations();
initializeResources();
initializeMessageRequestViewModel();
initializeListAdapter();
conversationViewModel.getSearchQuery().observe(getViewLifecycleOwner(), this::onSearchQueryUpdated);
return view;
}
@@ -394,7 +400,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
GiphyMp4PlaybackController.attach(list, callback, maxPlayback);
list.addItemDecoration(new GiphyMp4ItemDecoration(callback, translationY -> {
reactionsShade.setTranslationY(translationY);
reactionsShade.setTranslationY(translationY + list.getHeight());
return Unit.INSTANCE;
}), 0);
@@ -414,21 +420,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
@Override
public void onActivityCreated(Bundle bundle) {
super.onActivityCreated(bundle);
Log.d(TAG, "[onActivityCreated]");
initializeScrollButtonAnimations();
initializeResources();
initializeMessageRequestViewModel();
initializeListAdapter();
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
this.listener = (ConversationFragmentListener)activity;
public void onAttach(Context context) {
super.onAttach(context);
this.listener = (ConversationFragmentListener) getParentFragment();
}
@Override
@@ -525,7 +519,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
private void initializeMessageRequestViewModel() {
MessageRequestViewModel.Factory factory = new MessageRequestViewModel.Factory(requireContext());
messageRequestViewModel = ViewModelProviders.of(requireActivity(), factory).get(MessageRequestViewModel.class);
messageRequestViewModel = new ViewModelProvider(requireParentFragment(), factory).get(MessageRequestViewModel.class);
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
listener.onMessageRequest(messageRequestViewModel);
@@ -559,9 +553,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
conversationBanner.setAvatar(GlideApp.with(context), recipient);
conversationBanner.showBackgroundBubble(recipient.hasWallpaper());
String title = isSelf ? context.getString(R.string.note_to_self) : recipient.getDisplayNameOrUsername(context);
conversationBanner.setTitle(title);
conversationBanner.setAbout(recipient.getCombinedAboutAndEmoji());
String title = conversationBanner.setTitle(recipient);
conversationBanner.setAbout(recipient);
if (recipient.isGroup()) {
if (pendingMemberCount > 0) {
@@ -775,7 +768,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(new ActionItem(R.drawable.ic_save_24, getResources().getString(R.string.conversation_selection__menu_save), () -> {
items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> {
handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord());
actionMode.finish();
}));
@@ -817,19 +810,21 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
ViewUtil.animateIn(bottomActionBar, bottomActionBar.getEnterAnimation());
listener.onBottomActionBarVisibilityChanged(View.VISIBLE);
ViewKt.doOnPreDraw(bottomActionBar, new Function1<View, Unit>() {
@Override public Unit invoke(View view) {
if (view.getHeight() == 0 && view.getVisibility() == View.VISIBLE) {
ViewKt.doOnPreDraw(bottomActionBar, this);
return Unit.INSTANCE;
bottomActionBar.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
if (bottomActionBar.getHeight() == 0 && bottomActionBar.getVisibility() == View.VISIBLE) {
return false;
}
int bottomPadding = view.getHeight() + (int) DimensionUnit.DP.toPixels(18);
bottomActionBar.getViewTreeObserver().removeOnPreDrawListener(this);
int bottomPadding = bottomActionBar.getHeight() + (int) DimensionUnit.DP.toPixels(18);
list.setPadding(list.getPaddingLeft(), list.getPaddingTop(), list.getPaddingRight(), bottomPadding);
list.scrollBy(0, -(bottomPadding - additionalScrollOffset));
return Unit.INSTANCE;
return false;
}
});
} else {
@@ -905,7 +900,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
inlineDateDecoration = new StickyHeaderDecoration(adapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE);
list.addItemDecoration(inlineDateDecoration);
list.addItemDecoration(inlineDateDecoration, 0);
}
public void setLastSeen(long lastSeen) {
@@ -914,7 +909,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
lastSeenDecoration = new LastSeenHeader(getListAdapter(), lastSeen);
list.addItemDecoration(lastSeenDecoration);
list.addItemDecoration(lastSeenDecoration, 0);
}
private void handleCopyMessage(final Set<MultiselectPart> multiselectParts) {
@@ -1010,7 +1005,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
private void handleDisplayDetails(ConversationMessage message) {
startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message.getMessageRecord(), recipient.getId(), threadId));
MessageDetailsFragment.create(message.getMessageRecord(), recipient.getId()).show(getChildFragmentManager(), null);
}
private void handleForwardMessageParts(Set<MultiselectPart> multiselectParts) {
@@ -1033,11 +1028,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
private void handleReplyMessage(final ConversationMessage message) {
if (getActivity() != null) {
//noinspection ConstantConditions
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
}
listener.handleReplyMessage(message);
}
@@ -1173,7 +1163,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
return getListAdapter().isTypingViewEnabled();
}
public void onSearchQueryUpdated(@Nullable String query) {
private void onSearchQueryUpdated(@Nullable String query) {
if (getListAdapter() != null) {
getListAdapter().onSearchQueryUpdated(query);
}
@@ -1319,23 +1309,26 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
void setThreadId(long threadId);
void handleReplyMessage(ConversationMessage conversationMessage);
void onMessageActionToolbarOpened();
void onBottomActionBarVisibilityChanged(int visibility);
void onForwardClicked();
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
void handleReaction(@NonNull ConversationMessage conversationMessage,
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
void onCursorChanged();
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void onVoiceNotePause(@NonNull Uri uri);
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress);
void onVoiceNoteSeekTo(@NonNull Uri uri, double progress);
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
boolean isKeyboardOpen();
void setThreadId(long threadId);
void handleReplyMessage(ConversationMessage conversationMessage);
void onMessageActionToolbarOpened();
void onBottomActionBarVisibilityChanged(int visibility);
void onForwardClicked();
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
void handleReaction(@NonNull ConversationMessage conversationMessage,
@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener,
@NonNull SelectedConversationModel selectedConversationModel,
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
void onCursorChanged();
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void onVoiceNotePause(@NonNull Uri uri);
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress);
void onVoiceNoteResume(@NonNull Uri uri, long messageId);
void onVoiceNoteSeekTo(@NonNull Uri uri, double progress);
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
}
private class ConversationScrollListener extends OnScrollListener {
@@ -1442,16 +1435,91 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
multiselectItemDecoration.setFocusedItem(new MultiselectPart.Message(item.getConversationMessage()));
list.invalidateItemDecorations();
isReacting = true;
reactionsShade.setVisibility(View.VISIBLE);
list.setLayoutFrozen(true);
listener.handleReaction(item.getConversationMessage(), new ReactionsToolbarListener(item.getConversationMessage()), () -> {
isReacting = false;
reactionsShade.setVisibility(View.GONE);
list.setLayoutFrozen(false);
WindowUtil.setLightStatusBarFromTheme(requireActivity());
clearFocusedItem();
});
if (itemView instanceof ConversationItem) {
Uri audioUri = getAudioUriForLongClick(messageRecord);
if (audioUri != null) {
listener.onVoiceNotePause(audioUri);
}
Bitmap videoBitmap = null;
int childAdapterPosition = list.getChildAdapterPosition(itemView);
GiphyMp4ProjectionPlayerHolder mp4Holder = null;
if (childAdapterPosition != RecyclerView.NO_POSITION) {
mp4Holder = giphyMp4ProjectionRecycler.getCurrentHolder(childAdapterPosition);
if (mp4Holder != null && mp4Holder.isVisible()) {
mp4Holder.pause();
videoBitmap = mp4Holder.getBitmap();
mp4Holder.hide();
} else {
mp4Holder = null;
}
}
final GiphyMp4ProjectionPlayerHolder finalMp4Holder = mp4Holder;
ConversationItem conversationItem = (ConversationItem) itemView;
Bitmap bitmap = ConversationItemSelection.snapshotView(conversationItem, list, messageRecord, videoBitmap);
View focusedView = listener.isKeyboardOpen() ? conversationItem.getRootView().findFocus() : null;
final ConversationItemBodyBubble bodyBubble = conversationItem.bodyBubble;
SelectedConversationModel selectedConversationModel = new SelectedConversationModel(bitmap,
itemView.getX(),
itemView.getY() + list.getTranslationY(),
bodyBubble.getX(),
bodyBubble.getY(),
bodyBubble.getWidth(),
audioUri,
messageRecord.isOutgoing(),
focusedView);
bodyBubble.setVisibility(View.INVISIBLE);
conversationItem.reactionsView.setVisibility(View.INVISIBLE);
ViewUtil.hideKeyboard(requireContext(), conversationItem);
boolean showScrollButtons = conversationViewModel.getShowScrollButtons();
if (showScrollButtons) {
conversationViewModel.setShowScrollButtons(false);
}
listener.handleReaction(item.getConversationMessage(),
new ReactionsToolbarListener(item.getConversationMessage()),
selectedConversationModel,
new ConversationReactionOverlay.OnHideListener() {
@Override public void startHide() {
multiselectItemDecoration.hideShade(list);
ViewUtil.fadeOut(reactionsShade, getResources().getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE);
}
@Override public void onHide() {
list.setLayoutFrozen(false);
if (selectedConversationModel.getAudioUri() != null) {
listener.onVoiceNoteResume(selectedConversationModel.getAudioUri(), messageRecord.getId());
}
WindowUtil.setLightStatusBarFromTheme(requireActivity());
WindowUtil.setLightNavigationBarFromTheme(requireActivity());
clearFocusedItem();
if (finalMp4Holder != null) {
finalMp4Holder.show();
finalMp4Holder.resume();
}
bodyBubble.setVisibility(View.VISIBLE);
conversationItem.reactionsView.setVisibility(View.VISIBLE);
if (showScrollButtons) {
conversationViewModel.setShowScrollButtons(true);
}
}
});
}
} else {
clearFocusedItem();
((ConversationAdapter) list.getAdapter()).toggleSelection(item);
@@ -1461,6 +1529,20 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
@Nullable private Uri getAudioUriForLongClick(@NonNull MessageRecord messageRecord) {
VoiceNotePlaybackState playbackState = listener.getVoiceNoteMediaController().getVoiceNotePlaybackState().getValue();
if (playbackState == null || !playbackState.isPlaying()) {
return null;
}
if (!MessageRecordUtil.hasAudio(messageRecord) || !messageRecord.isMms()) {
return null;
}
Uri messageUri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri();
return playbackState.getUri().equals(messageUri) ? messageUri : null;
}
@Override
public void onQuoteClicked(MmsMessageRecord messageRecord) {
if (messageRecord.getQuote() == null) {
@@ -1493,7 +1575,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms) {
if (getContext() != null && getActivity() != null) {
startActivity(LongMessageActivity.getIntent(getContext(), conversationRecipientId, messageId, isMms));
LongMessageFragment.create(messageId, isMms).show(getChildFragmentManager(), null);
}
}
@@ -1594,16 +1676,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms) {
if (getContext() == null) return;
if (getParentFragment() == null) return;
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null);
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(getParentFragmentManager(), null);
}
@Override
public void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) {
if (getContext() == null) return;
if (getParentFragment() == null) return;
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM");
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(getParentFragmentManager(), "BOTTOM");
}
@Override
@@ -1659,7 +1741,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange) {
GroupsV1MigrationInfoBottomSheetDialogFragment.show(requireFragmentManager(), membershipChange);
if (getParentFragment() == null) {
return;
}
GroupsV1MigrationInfoBottomSheetDialogFragment.show(getParentFragmentManager(), membershipChange);
}
@Override
@@ -1693,7 +1779,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
.setView(R.layout.safety_number_changed_learn_more_dialog)
.setPositiveButton(R.string.ConversationFragment_verify, (d, w) -> {
SimpleTask.run(getLifecycle(), () -> {
return ApplicationDependencies.getIdentityStore().getIdentityRecord(recipient.getId());
return ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.getId());
}, identityRecord -> {
if (identityRecord.isPresent()) {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord.get()));
@@ -1763,6 +1849,25 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
public void onChangeNumberUpdateContact(@NonNull Recipient recipient) {
startActivity(RecipientExporter.export(recipient).asAddContactIntent());
}
@Override
public void onCallToAction(@NonNull String action) {
}
@Override
public void onDonateClicked() {
if (SignalStore.donationsValues().isLikelyASustainer()) {
NavHostFragment navHostFragment = NavHostFragment.create(R.navigation.boosts);
requireActivity().getSupportFragmentManager()
.beginTransaction()
.add(navHostFragment, "boost_nav")
.commitNow();
} else {
startActivity(AppSettingsActivity.subscriptions(requireContext()));
}
}
}
public void refreshList() {
@@ -1875,7 +1980,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener {
private class ReactionsToolbarListener implements ConversationReactionOverlay.OnActionSelectedListener {
private final ConversationMessage conversationMessage;
@@ -1884,16 +1989,32 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_info: handleDisplayDetails(conversationMessage); return true;
case R.id.action_delete: handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet()); return true;
case R.id.action_copy: handleCopyMessage(conversationMessage.getMultiselectCollection().toSet()); return true;
case R.id.action_reply: handleReplyMessage(conversationMessage); return true;
case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true;
case R.id.action_forward: handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet()); return true;
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true;
default: return false;
public void onActionSelected(@NonNull ConversationReactionOverlay.Action action) {
switch (action) {
case REPLY:
handleReplyMessage(conversationMessage);
break;
case FORWARD:
handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet());
break;
case RESEND:
handleResendMessage(conversationMessage.getMessageRecord());
break;
case DOWNLOAD:
handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord());
break;
case COPY:
handleCopyMessage(conversationMessage.getMultiselectCollection().toSet());
break;
case MULTISELECT:
handleEnterMultiSelect(conversationMessage);
break;
case VIEW_INFO:
handleDisplayDetails(conversationMessage);
break;
case DELETE:
handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet());
break;
}
}
}

View File

@@ -45,6 +45,7 @@ import android.view.MotionEvent;
import android.view.TouchDelegate;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -122,6 +123,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ProjectionList;
import org.thoughtcrime.securesms.util.SearchUtil;
@@ -142,6 +144,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* A view that displays an individual conversation item within a conversation
@@ -160,6 +163,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private static final Rect SWIPE_RECT = new Rect();
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
private static final int SHRINK_BUBBLE_DELAY_MILLIS = 100;
private static final long MAX_CLUSTERING_TIME_DIFF = TimeUnit.MINUTES.toMillis(3);
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Optional<MessageRecord> nextMessageRecord;
@@ -196,6 +203,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Stub<LinkPreviewView> linkPreviewStub;
private Stub<BorderlessImageView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private Stub<Button> callToActionStub;
private @Nullable EventListener eventListener;
private int defaultBubbleColor;
@@ -224,6 +232,25 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private float lastYDownRelativeToThis;
private ProjectionList colorizerProjections = new ProjectionList(3);
private final Runnable shrinkBubble = new Runnable() {
@Override
public void run() {
bodyBubble.animate()
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR)
.setUpdateListener(animation -> {
View parent = (View) getParent();
if (parent != null) {
parent.invalidate();
}
});
reactionsView.animate()
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR);
}
};
public ConversationItem(Context context) {
this(context, null);
}
@@ -259,6 +286,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub));
this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub));
this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub));
this.callToActionStub = ViewUtil.findStubById(this, R.id.conversation_item_call_to_action_stub);
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
this.quoteView = findViewById(R.id.quote_view);
this.reply = findViewById(R.id.reply_icon_wrapper);
@@ -343,6 +371,27 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setGroupAuthorColor(messageRecord, hasWallpaper, colorizer);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
getHandler().postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
getHandler().removeCallbacks(shrinkBubble);
bodyBubble.animate()
.scaleX(1.0f)
.scaleY(1.0f);
reactionsView.animate()
.scaleX(1.0f)
.scaleY(1.0f);
break;
}
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
@@ -407,6 +456,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
!hasAudio(messageRecord) &&
isFooterVisible(messageRecord, nextMessageRecord, groupThread) &&
!bodyText.isJumbomoji() &&
conversationMessage.getBottomButton() == null &&
!StringUtil.hasMixedTextDirection(bodyText.getText()) &&
bodyText.getLastLineWidth() > 0)
{
TextView dateView = footer.getDateView();
@@ -513,6 +564,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
availableWidth = bodyBubble.getMeasuredWidth() - bodyBubble.getPaddingLeft() - bodyBubble.getPaddingRight();
}
availableWidth = Math.min(availableWidth, getMaxBubbleWidth());
availableWidth -= ViewUtil.getLeftMargin(forView) + ViewUtil.getRightMargin(forView);
return availableWidth;
@@ -884,6 +937,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyText.setText(StringUtil.trim(styledText));
bodyText.setVisibility(View.VISIBLE);
if (conversationMessage.getBottomButton() != null) {
callToActionStub.get().setVisibility(View.VISIBLE);
callToActionStub.get().setText(conversationMessage.getBottomButton().getLabel());
callToActionStub.get().setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onCallToAction(conversationMessage.getBottomButton().getAction());
}
});
} else if (callToActionStub.resolved()) {
callToActionStub.get().setVisibility(View.GONE);
}
}
}
@@ -1288,6 +1353,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
if (conversationMessage.hasStyleLinks()) {
for (PlaceholderURLSpan placeholder : messageBody.getSpans(0, messageBody.length(), PlaceholderURLSpan.class)) {
int start = messageBody.getSpanStart(placeholder);
int end = messageBody.getSpanEnd(placeholder);
URLSpan span = new InterceptableLongClickCopyLinkSpan(placeholder.getValue(),
urlClickListener,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false);
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody);
for (Annotation annotation : mentionAnnotations) {
messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
@@ -1485,7 +1563,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
contactPhotoHolder.setVisibility(VISIBLE);
if (!previous.isPresent() || previous.get().isUpdate() || !current.getRecipient().equals(previous.get().getRecipient()) ||
!DateUtils.isSameDay(previous.get().getTimestamp(), current.getTimestamp()))
!DateUtils.isSameDay(previous.get().getTimestamp(), current.getTimestamp()) || !isWithinClusteringTime(current, previous.get()))
{
groupSenderHolder.setVisibility(VISIBLE);
@@ -1499,7 +1577,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
groupSenderHolder.setVisibility(GONE);
}
if (!next.isPresent() || next.get().isUpdate() || !current.getRecipient().equals(next.get().getRecipient())) {
if (!next.isPresent() || next.get().isUpdate() || !current.getRecipient().equals(next.get().getRecipient()) || !isWithinClusteringTime(current, next.get())) {
contactPhoto.setVisibility(VISIBLE);
badgeImageView.setVisibility(VISIBLE);
} else {
@@ -1599,20 +1677,21 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
if (isGroupThread) {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
!current.getRecipient().equals(previous.get().getRecipient());
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get());
} else {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
current.isOutgoing() != previous.get().isOutgoing();
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get());
}
}
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (isGroupThread) {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty();
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get());
} else {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty();
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty() || next.get().isSecure() != current.isSecure() ||
!isWithinClusteringTime(current, next.get());
}
}
@@ -1627,6 +1706,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
current.isFailed() || current.isRateLimited() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread);
}
private static boolean isWithinClusteringTime(@NonNull MessageRecord lhs, @NonNull MessageRecord rhs) {
long timeDiff = Math.abs(lhs.getDateSent() - rhs.getDateSent());
return timeDiff <= MAX_CLUSTERING_TIME_DIFF;
}
private void setMessageSpacing(@NonNull Context context, @NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse);
int spacingBottom = spacingTop;
@@ -1735,7 +1819,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
if (mediaThumbnailStub != null && mediaThumbnailStub.isResolvable()) {
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.require(), mediaThumbnailStub.require().getCorners())
ConversationItemThumbnail thumbnail = mediaThumbnailStub.require();
return Projection.relativeToParent(recyclerView, thumbnail, thumbnail.getCorners())
.scale(bodyBubble.getScaleX())
.translateX(Util.halfOffsetFromScale(thumbnail.getWidth(), bodyBubble.getScaleX()))
.translateY(Util.halfOffsetFromScale(thumbnail.getHeight(), bodyBubble.getScaleY()))
.translateY(getTranslationY())
.translateX(bodyBubble.getTranslationX())
.translateX(getTranslationX());
@@ -1752,6 +1840,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return mediaThumbnailStub != null && mediaThumbnailStub.isResolvable() && canPlayContent;
}
@Override
public boolean shouldProjectContent() {
return canPlayContent() && bodyBubble.getVisibility() == VISIBLE;
}
@Override
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
colorizerProjections.clear();
@@ -1759,34 +1852,70 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRecord.isOutgoing() &&
!hasNoBubble(messageRecord) &&
!messageRecord.isRemoteDelete() &&
bodyBubbleCorners != null)
bodyBubbleCorners != null &&
bodyBubble.getVisibility() == VISIBLE)
{
Projection bodyBubbleToRoot = Projection.relativeToParent(coordinateRoot, bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX());
Projection videoToBubble = bodyBubble.getVideoPlayerProjection();
float translationX = Util.halfOffsetFromScale(bodyBubble.getWidth(), bodyBubble.getScaleX());
float translationY = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY());
if (videoToBubble != null) {
Projection videoToRoot = Projection.translateFromDescendantToParentCoords(videoToBubble, bodyBubble, coordinateRoot);
colorizerProjections.addAll(Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot));
List<Projection> projections = Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot);
if (!projections.isEmpty()) {
projections.get(0)
.scale(bodyBubble.getScaleX())
.translateX(translationX)
.translateY(translationY);
projections.get(1)
.scale(bodyBubble.getScaleX())
.translateX(translationX)
.translateY(-translationY);
}
colorizerProjections.addAll(projections);
} else {
colorizerProjections.add(bodyBubbleToRoot);
colorizerProjections.add(
bodyBubbleToRoot.scale(bodyBubble.getScaleX())
.translateX(translationX)
.translateY(translationY)
);
}
}
if (messageRecord.isOutgoing() &&
hasNoBubble(messageRecord) &&
hasWallpaper)
hasWallpaper &&
bodyBubble.getVisibility() == VISIBLE)
{
Projection footerProjection = getActiveFooter(messageRecord).getProjection(coordinateRoot);
ConversationItemFooter footer = getActiveFooter(messageRecord);
Projection footerProjection = footer.getProjection(coordinateRoot);
if (footerProjection != null) {
colorizerProjections.add(footerProjection.translateX(bodyBubble.getTranslationX()));
colorizerProjections.add(
footerProjection.translateX(bodyBubble.getTranslationX())
.scale(bodyBubble.getScaleX())
.translateX(Util.halfOffsetFromScale(footer.getWidth(), bodyBubble.getScaleX()))
.translateY(-Util.halfOffsetFromScale(footer.getHeight(), bodyBubble.getScaleY()))
);
}
}
if (!messageRecord.isOutgoing() &&
hasQuote(messageRecord) &&
quoteView != null)
quoteView != null &&
bodyBubble.getVisibility() == VISIBLE)
{
bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble));
colorizerProjections.add(quoteView.getProjection(coordinateRoot).translateX(bodyBubble.getTranslationX() + this.getTranslationX()));
float bubbleOffsetFromScale = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY());
Projection cProj = quoteView.getProjection(coordinateRoot)
.translateX(bodyBubble.getTranslationX() + this.getTranslationX() + Util.halfOffsetFromScale(quoteView.getWidth(), bodyBubble.getScaleX()))
.translateY(bubbleOffsetFromScale - quoteView.getY() + (quoteView.getY() * bodyBubble.getScaleY()))
.scale(bodyBubble.getScaleX());
colorizerProjections.add(cProj);
}
for (int i = 0; i < colorizerProjections.size(); i++) {

View File

@@ -0,0 +1,124 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Bitmap
import android.graphics.Path
import android.view.ViewGroup
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withClip
import androidx.core.graphics.withTranslation
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.hasNoBubble
object ConversationItemSelection {
@JvmStatic
fun snapshotView(
conversationItem: ConversationItem,
list: RecyclerView,
messageRecord: MessageRecord,
videoBitmap: Bitmap?,
): Bitmap {
val isOutgoing = messageRecord.isOutgoing
val hasNoBubble = messageRecord.hasNoBubble(conversationItem.context)
return snapshotMessage(
conversationItem = conversationItem,
list = list,
videoBitmap = videoBitmap,
drawConversationItem = !isOutgoing || hasNoBubble,
hasReaction = messageRecord.reactions.isNotEmpty(),
)
}
private fun snapshotMessage(
conversationItem: ConversationItem,
list: RecyclerView,
videoBitmap: Bitmap?,
drawConversationItem: Boolean,
hasReaction: Boolean,
): Bitmap {
val bodyBubble = conversationItem.bodyBubble
val reactionsView = conversationItem.reactionsView
val originalScale = bodyBubble.scaleX
bodyBubble.scaleX = 1.0f
bodyBubble.scaleY = 1.0f
val projections = conversationItem.getColorizerProjections(list)
val path = Path()
val xTranslation = -conversationItem.x - bodyBubble.x
val yTranslation = -conversationItem.y - bodyBubble.y
val mp4Projection = conversationItem.getGiphyMp4PlayableProjection(list)
var scaledVideoBitmap = videoBitmap
if (videoBitmap != null) {
scaledVideoBitmap = Bitmap.createScaledBitmap(
videoBitmap,
(videoBitmap.width / originalScale).toInt(),
(videoBitmap.height / originalScale).toInt(),
true
)
mp4Projection.translateX(xTranslation)
mp4Projection.translateY(yTranslation)
mp4Projection.applyToPath(path)
}
projections.use {
it.forEach { p ->
p.translateX(xTranslation)
p.translateY(yTranslation)
p.applyToPath(path)
}
}
conversationItem.destroyAllDrawingCaches()
var bitmapHeight = bodyBubble.height
if (hasReaction) {
bitmapHeight += (reactionsView.height - DimensionUnit.DP.toPixels(4f)).toInt()
}
return createBitmap(bodyBubble.width, bitmapHeight).applyCanvas {
if (drawConversationItem) {
bodyBubble.draw(this)
}
withClip(path) {
withTranslation(x = xTranslation, y = yTranslation) {
list.draw(this)
if (scaledVideoBitmap != null) {
drawBitmap(scaledVideoBitmap, mp4Projection.x - xTranslation, mp4Projection.y - yTranslation, null)
}
}
}
withTranslation(
x = reactionsView.x - bodyBubble.x,
y = reactionsView.y - bodyBubble.y
) {
reactionsView.draw(this)
}
}.also {
mp4Projection.release()
bodyBubble.scaleX = originalScale
bodyBubble.scaleY = originalScale
}
}
}
private fun ViewGroup.destroyAllDrawingCaches() {
children.forEach {
it.destroyDrawingCache()
if (it is ViewGroup) {
it.destroyAllDrawingCaches()
}
}
}

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import java.security.MessageDigest;
import java.util.Collections;
@@ -26,10 +27,11 @@ import java.util.List;
* for various presentations.
*/
public class ConversationMessage {
@NonNull private final MessageRecord messageRecord;
@NonNull private final List<Mention> mentions;
@Nullable private final SpannableString body;
@NonNull private final MultiselectCollection multiselectCollection;
@NonNull private final MessageRecord messageRecord;
@NonNull private final List<Mention> mentions;
@Nullable private final SpannableString body;
@NonNull private final MultiselectCollection multiselectCollection;
@NonNull private final MessageStyler.Result styleResult;
private ConversationMessage(@NonNull MessageRecord messageRecord) {
this(messageRecord, null, null);
@@ -40,13 +42,26 @@ public class ConversationMessage {
@Nullable List<Mention> mentions)
{
this.messageRecord = messageRecord;
this.body = body != null ? SpannableString.valueOf(body) : null;
this.mentions = mentions != null ? mentions : Collections.emptyList();
if (body != null) {
this.body = SpannableString.valueOf(body);
} else if (messageRecord.hasMessageRanges()) {
this.body = SpannableString.valueOf(messageRecord.getBody());
} else {
this.body = null;
}
if (!this.mentions.isEmpty() && this.body != null) {
MentionAnnotation.setMentionAnnotations(this.body, this.mentions);
}
if (this.body != null && messageRecord.hasMessageRanges()) {
styleResult = MessageStyler.style(messageRecord.requireMessageRanges(), this.body);
} else {
styleResult = MessageStyler.Result.none();
}
multiselectCollection = Multiselect.getParts(this);
}
@@ -86,6 +101,14 @@ public class ConversationMessage {
return (body != null) ? body : messageRecord.getDisplayBody(context);
}
public boolean hasStyleLinks() {
return styleResult.getHasStyleLinks();
}
public @Nullable BodyRangeList.BodyRange.Button getBottomButton() {
return styleResult.getBottomButton();
}
/**
* Factory providing multiple ways of creating {@link ConversationMessage}s.
*/

View File

@@ -10,6 +10,7 @@ import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityOptionsCompat;
import org.signal.core.util.logging.Log;
@@ -47,15 +48,14 @@ public class ConversationPopupActivity extends ConversationActivity {
else getWindow().setLayout((int) (width * .7), (int) (height * .75));
super.onCreate(bundle, ready);
titleView.setOnClickListener(null);
}
@Override
protected void onResume() {
super.onResume();
composeText.requestFocus();
quickAttachmentToggle.disable();
getTitleView().setOnClickListener(null);
getComposeText().requestFocus();
getQuickAttachmentToggle().disable();
}
@Override
@@ -101,21 +101,20 @@ public class ConversationPopupActivity extends ConversationActivity {
}
@Override
protected void initializeActionBar() {
super.initializeActionBar();
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
public void onInitializeToolbar(Toolbar toolbar) {
}
@Override
protected void sendComplete(long threadId) {
super.sendComplete(threadId);
public void onSendComplete(long threadId) {
finish();
}
@Override
protected void updateReminders() {
if (reminderView.resolved()) {
reminderView.get().setVisibility(View.GONE);
public boolean onUpdateReminders() {
if (getReminderView().resolved()) {
getReminderView().get().setVisibility(View.GONE);
}
return false;
}
}

View File

@@ -5,7 +5,6 @@ import android.graphics.PointF;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -24,7 +23,7 @@ final class ConversationReactionDelegate {
private final PointF lastSeenDownPoint = new PointF();
private ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener;
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener;
private ConversationReactionOverlay.OnHideListener onHideListener;
ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
@@ -38,9 +37,10 @@ final class ConversationReactionDelegate {
void show(@NonNull Activity activity,
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
boolean isNonAdminInAnnouncementGroup)
boolean isNonAdminInAnnouncementGroup,
@NonNull SelectedConversationModel selectedConversationModel)
{
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup);
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel);
}
void hide() {
@@ -59,11 +59,11 @@ final class ConversationReactionDelegate {
}
}
void setOnToolbarItemClickedListener(@NonNull Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) {
this.onToolbarItemClickedListener = onToolbarItemClickedListener;
void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
if (overlayStub.resolved()) {
overlayStub.get().setOnToolbarItemClickedListener(onToolbarItemClickedListener);
overlayStub.get().setOnActionSelectedListener(onActionSelectedListener);
}
}
@@ -99,7 +99,7 @@ final class ConversationReactionDelegate {
overlay.requestFitSystemWindows();
overlay.setOnHideListener(onHideListener);
overlay.setOnToolbarItemClickedListener(onToolbarItemClickedListener);
overlay.setOnActionSelectedListener(onActionSelectedListener);
overlay.setOnReactionSelectedListener(onReactionSelectedListener);
return overlay;

View File

@@ -2,34 +2,42 @@ package org.thoughtcrime.securesms.conversation;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.os.Build;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.RelativeLayout;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.annotation.RequiresApi;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -39,10 +47,12 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import java.util.LinkedList;
import java.util.ArrayList;
import java.util.List;
public final class ConversationReactionOverlay extends RelativeLayout {
import kotlin.Unit;
public final class ConversationReactionOverlay extends FrameLayout {
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
@@ -54,45 +64,45 @@ public final class ConversationReactionOverlay extends RelativeLayout {
private final Boundary verticalScrubBoundary = new Boundary();
private final PointF deadzoneTouchPoint = new PointF();
private Activity activity;
private Recipient conversationRecipient;
private MessageRecord messageRecord;
private OverlayState overlayState = OverlayState.HIDDEN;
private boolean isNonAdminInAnnouncementGroup;
private Activity activity;
private Recipient conversationRecipient;
private MessageRecord messageRecord;
private SelectedConversationModel selectedConversationModel;
private OverlayState overlayState = OverlayState.HIDDEN;
private boolean isNonAdminInAnnouncementGroup;
private boolean downIsOurs;
private boolean isToolbarTouch;
private int selected = -1;
private int customEmojiIndex;
private int originalStatusBarColor;
private int originalNavigationBarColor;
private View dropdownAnchor;
private View toolbarShade;
private View inputShade;
private View conversationItem;
private View backgroundView;
private ConstraintLayout foregroundView;
private View selectedView;
private EmojiImageView[] emojiViews;
private Toolbar toolbar;
private ConversationContextMenu contextMenu;
private float touchDownDeadZoneSize;
private float distanceFromTouchDownPointToTopOfScrubberDeadZone;
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
private int scrubberDistanceFromTouchDown;
private int scrubberHeight;
private int scrubberWidth;
private int actionBarHeight;
private int selectedVerticalTranslation;
private int scrubberHorizontalMargin;
private int animationEmojiStartDelayFactor;
private int statusBarHeight;
private int bottomNavigationBarHeight;
private OnReactionSelectedListener onReactionSelectedListener;
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
private OnActionSelectedListener onActionSelectedListener;
private OnHideListener onHideListener;
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet revealMaskAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet();
private AnimatorSet hideMaskAnimatorSet = new AnimatorSet();
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
public ConversationReactionOverlay(@NonNull Context context) {
super(context);
@@ -106,13 +116,13 @@ public final class ConversationReactionOverlay extends RelativeLayout {
protected void onFinishInflate() {
super.onFinishInflate();
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
toolbar = findViewById(R.id.conversation_reaction_toolbar);
toolbar.setOnMenuItemClickListener(this::handleToolbarItemClicked);
toolbar.setNavigationOnClickListener(view -> hide());
dropdownAnchor = findViewById(R.id.dropdown_anchor);
toolbarShade = findViewById(R.id.toolbar_shade);
inputShade = findViewById(R.id.input_shade);
conversationItem = findViewById(R.id.conversation_item);
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
findViewById(R.id.reaction_2),
@@ -124,16 +134,12 @@ public final class ConversationReactionOverlay extends RelativeLayout {
customEmojiIndex = emojiViews.length - 1;
distanceFromTouchDownPointToTopOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_top);
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
scrubberDistanceFromTouchDown = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_distance);
scrubberHeight = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_height);
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
actionBarHeight = (int) ThemeUtil.getThemedDimen(getContext(), R.attr.actionBarSize);
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
@@ -144,7 +150,8 @@ public final class ConversationReactionOverlay extends RelativeLayout {
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
@NonNull PointF lastSeenDownPoint,
boolean isNonAdminInAnnouncementGroup)
boolean isNonAdminInAnnouncementGroup,
@NonNull SelectedConversationModel selectedConversationModel)
{
if (overlayState != OverlayState.HIDDEN) {
return;
@@ -152,77 +159,325 @@ public final class ConversationReactionOverlay extends RelativeLayout {
this.messageRecord = conversationMessage.getMessageRecord();
this.conversationRecipient = conversationRecipient;
this.selectedConversationModel = selectedConversationModel;
this.isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
setupToolbarMenuItems(conversationMessage);
setupSelectedEmoji();
if (Build.VERSION.SDK_INT >= 21) {
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
View navigationBarBackground = activity.findViewById(android.R.id.navigationBarBackground);
bottomNavigationBarHeight = navigationBarBackground == null ? 0 : navigationBarBackground.getHeight();
} else {
statusBarHeight = ViewUtil.getStatusBarHeight(this);
statusBarHeight = ViewUtil.getStatusBarHeight(this);
bottomNavigationBarHeight = ViewUtil.getNavigationBarHeight(this);
}
final float scrubberTranslationY = Math.max(-scrubberDistanceFromTouchDown + actionBarHeight,
lastSeenDownPoint.y - scrubberHeight - scrubberDistanceFromTouchDown - statusBarHeight);
boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
if (isLandscape) {
bottomNavigationBarHeight = 0;
}
final float halfWidth = scrubberWidth / 2f + scrubberHorizontalMargin;
final float screenWidth = getResources().getDisplayMetrics().widthPixels;
final float downX = ViewUtil.isLtr(this) ? lastSeenDownPoint.x : screenWidth - lastSeenDownPoint.x;
final float scrubberTranslationX = Util.clamp(downX - halfWidth,
scrubberHorizontalMargin,
screenWidth + scrubberHorizontalMargin - halfWidth * 2) * (ViewUtil.isLtr(this) ? 1 : -1);
toolbarShade.setVisibility(VISIBLE);
toolbarShade.setAlpha(1f);
backgroundView.setTranslationX(scrubberTranslationX);
backgroundView.setTranslationY(scrubberTranslationY);
inputShade.setVisibility(VISIBLE);
inputShade.setAlpha(1f);
foregroundView.setTranslationX(scrubberTranslationX);
foregroundView.setTranslationY(scrubberTranslationY);
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
verticalScrubBoundary.update(lastSeenDownPoint.y - distanceFromTouchDownPointToTopOfScrubberDeadZone,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
conversationItem.setLayoutParams(new LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
conversationItem.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
hideAnimatorSet.end();
toolbar.setVisibility(VISIBLE);
setVisibility(View.VISIBLE);
revealAnimatorSet.start();
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
conversationItem.setScaleX(ConversationItem.LONG_PRESS_SCALE_FACTOR);
conversationItem.setScaleY(ConversationItem.LONG_PRESS_SCALE_FACTOR);
setVisibility(View.INVISIBLE);
if (Build.VERSION.SDK_INT >= 21) {
this.activity = activity;
originalStatusBarColor = activity.getWindow().getStatusBarColor();
WindowUtil.setStatusBarColor(activity.getWindow(), ContextCompat.getColor(getContext(), R.color.action_mode_status_bar));
updateSystemUiOnShow(activity);
}
if (!ThemeUtil.isDarkTheme(getContext())) {
WindowUtil.setLightStatusBar(activity.getWindow());
ViewKt.doOnLayout(this, v -> {
showAfterLayout(activity, conversationMessage, lastSeenDownPoint, isMessageOnLeft);
return Unit.INSTANCE;
});
}
private void showAfterLayout(@NonNull Activity activity,
@NonNull ConversationMessage conversationMessage,
@NonNull PointF lastSeenDownPoint,
boolean isMessageOnLeft) {
updateToolbarShade(activity);
updateInputShade(activity);
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage));
conversationItem.setX(selectedConversationModel.getBubbleX());
conversationItem.setY(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
int overlayHeight = getHeight() - bottomNavigationBarHeight;
int bubbleWidth = selectedConversationModel.getBubbleWidth();
float endX = selectedConversationModel.getBubbleX();
float endY = conversationItem.getY();
float endApparentTop = endY;
float endScale = 1f;
float menuPadding = DimensionUnit.DP.toPixels(12f);
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
int reactionBarHeight = backgroundView.getHeight();
float reactionBarBackgroundY;
if (isWideLayout) {
boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
if (everythingFitsVertically) {
boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
if (reactionBarFitsAboveItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
} else {
endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItem.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
float reactionBarOffset = DimensionUnit.DP.toPixels(48);
float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset - conversationItemSnapshot.getHeight(), 0);
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
if (everythingFitsVertically) {
float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
if (menuFitsBelowItem) {
if (conversationItem.getY() < 0) {
endY = 0;
}
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
if (reactionBarBackgroundY <= reactionBarTopPadding) {
endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
}
} else {
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
}
endApparentTop = endY;
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
} else {
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
int menuHeight = contextMenu.getHeight();
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
if (fitsVertically) {
float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
if (menuFitsBelowItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
if (reactionBarBackgroundY < reactionBarTopPadding) {
endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
reactionBarBackgroundY = reactionBarTopPadding;
}
} else {
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
}
endApparentTop = endY;
} else {
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
reactionBarBackgroundY = reactionBarTopPadding;
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
}
}
}
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
hideAnimatorSet.end();
setVisibility(View.VISIBLE);
float scrubberX;
if (isMessageOnLeft) {
scrubberX = scrubberHorizontalMargin;
} else {
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
}
foregroundView.setX(scrubberX);
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
backgroundView.setX(scrubberX);
backgroundView.setY(reactionBarBackgroundY);
verticalScrubBoundary.update(reactionBarBackgroundY,
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
updateBoundsOnLayoutChanged();
revealAnimatorSet.start();
if (isWideLayout) {
float scrubberRight = scrubberX + scrubberWidth;
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
} else {
float contentX = selectedConversationModel.getBubbleX();
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
}
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
conversationItem.animate()
.x(endX)
.y(endY)
.scaleX(endScale)
.scaleY(endScale)
.setDuration(revealDuration);
}
private float getReactionBarOffsetForTouch(@NonNull PointF touchPoint,
float contextMenuTop,
float contextMenuPadding,
float reactionBarOffset,
int reactionBarHeight,
float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
float messageTop)
{
float adjustedTouchY = touchPoint.y - statusBarHeight;
float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
}
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
}
private void updateToolbarShade(@NonNull Activity activity) {
View toolbar = activity.findViewById(R.id.toolbar);
View bannerContainer = activity.findViewById(R.id.conversation_banner_container);
LayoutParams layoutParams = (LayoutParams) toolbarShade.getLayoutParams();
layoutParams.height = toolbar.getHeight() + bannerContainer.getHeight();
toolbarShade.setLayoutParams(layoutParams);
}
private void updateInputShade(@NonNull Activity activity) {
LayoutParams layoutParams = (LayoutParams) inputShade.getLayoutParams();
layoutParams.bottomMargin = bottomNavigationBarHeight;
layoutParams.height = getInputPanelHeight(activity);
inputShade.setLayoutParams(layoutParams);
}
private int getInputPanelHeight(@NonNull Activity activity) {
View bottomPanel = activity.findViewById(R.id.conversation_activity_panel_parent);
View emojiDrawer = activity.findViewById(R.id.emoji_drawer);
return bottomPanel.getHeight() + (emojiDrawer != null && emojiDrawer.getVisibility() == VISIBLE ? emojiDrawer.getHeight() : 0);
}
@RequiresApi(api = 21)
private void updateSystemUiOnShow(@NonNull Activity activity) {
Window window = activity.getWindow();
int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui);
originalStatusBarColor = window.getStatusBarColor();
WindowUtil.setStatusBarColor(window, barColor);
originalNavigationBarColor = window.getNavigationBarColor();
WindowUtil.setNavigationBarColor(window, barColor);
if (!ThemeUtil.isDarkTheme(getContext())) {
WindowUtil.clearLightStatusBar(window);
WindowUtil.clearLightNavigationBar(window);
}
}
public void hide() {
hideInternal(hideAnimatorSet, onHideListener);
hideInternal(onHideListener);
}
public void hideForReactWithAny() {
hideInternal(hideAnimatorSet, null);
hideInternal(onHideListener);
}
private void hideInternal(@NonNull AnimatorSet hideAnimatorSet, @Nullable OnHideListener onHideListener) {
private void hideInternal(@Nullable OnHideListener onHideListener) {
overlayState = OverlayState.HIDDEN;
revealAnimatorSet.end();
hideAnimatorSet.start();
AnimatorSet animatorSet = newHideAnimatorSet();
hideAnimatorSet = animatorSet;
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
WindowUtil.setStatusBarColor(activity.getWindow(), originalStatusBarColor);
WindowUtil.clearLightStatusBar(activity.getWindow());
activity = null;
}
revealAnimatorSet.end();
animatorSet.start();
if (onHideListener != null) {
onHideListener.onHide();
onHideListener.startHide();
}
if (selectedConversationModel.getFocusedView() != null) {
ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView());
}
animatorSet.addListener(new AnimationCompleteListener() {
@Override public void onAnimationEnd(Animator animation) {
animatorSet.removeListener(this);
toolbarShade.setVisibility(INVISIBLE);
inputShade.setVisibility(INVISIBLE);
if (onHideListener != null) {
onHideListener.onHide();
}
}
});
if (contextMenu != null) {
contextMenu.dismiss();
}
}
@@ -238,6 +493,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
updateBoundsOnLayoutChanged();
}
private void updateBoundsOnLayoutChanged() {
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
@@ -300,24 +559,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
}
}
if (isToolbarTouch) {
if (motionEvent.getAction() == MotionEvent.ACTION_CANCEL || motionEvent.getAction() == MotionEvent.ACTION_UP) {
isToolbarTouch = false;
}
return false;
}
switch (motionEvent.getAction()) {
case MotionEvent.ACTION_DOWN:
selected = getSelectedIndexViaDownEvent(motionEvent);
if (selected == -1) {
if (motionEvent.getY() < toolbar.getHeight() + statusBarHeight) {
isToolbarTouch = true;
return false;
}
}
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
overlayState = OverlayState.DEADZONE;
downIsOurs = true;
@@ -439,7 +684,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
}
private void handleUpEvent() {
if (selected != -1 && onReactionSelectedListener != null) {
if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
if (selected == customEmojiIndex) {
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
} else {
@@ -454,8 +699,8 @@ public final class ConversationReactionOverlay extends RelativeLayout {
this.onReactionSelectedListener = onReactionSelectedListener;
}
public void setOnToolbarItemClickedListener(@Nullable Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) {
this.onToolbarItemClickedListener = onToolbarItemClickedListener;
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
}
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
@@ -474,29 +719,69 @@ public final class ConversationReactionOverlay extends RelativeLayout {
.orElse(null);
}
private void setupToolbarMenuItems(@NonNull ConversationMessage conversationMessage) {
private @NonNull List<ActionItem> getMenuActionItems(@NonNull ConversationMessage conversationMessage) {
MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup);
toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction());
toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction());
toolbar.getMenu().findItem(R.id.action_forward).setVisible(menuState.shouldShowForwardAction());
toolbar.getMenu().findItem(R.id.action_reply).setVisible(menuState.shouldShowReplyAction());
}
List<ActionItem> items = new ArrayList<>();
private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) {
hide();
if (onToolbarItemClickedListener == null) {
return false;
if (menuState.shouldShowReplyAction()) {
items.add(new ActionItem(R.drawable.ic_reply_24_tinted, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
}
return onToolbarItemClickedListener.onMenuItemClick(menuItem);
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.ic_forward_24_tinted, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD)));
}
if (menuState.shouldShowResendAction()) {
items.add(new ActionItem(R.drawable.ic_retry_24, getResources().getString(R.string.conversation_selection__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
}
if (menuState.shouldShowSaveAttachmentAction()) {
items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
}
if (menuState.shouldShowCopyAction()) {
items.add(new ActionItem(R.drawable.ic_copy_24_tinted, getResources().getString(R.string.conversation_selection__menu_copy), () -> handleActionItemClicked(Action.COPY)));
}
items.add(new ActionItem(R.drawable.ic_select_24_tinted, getResources().getString(R.string.conversation_selection__menu_multi_select), () -> handleActionItemClicked(Action.MULTISELECT)));
if (menuState.shouldShowDetailsAction()) {
items.add(new ActionItem(R.drawable.ic_info_tinted_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
}
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
items.add(new ActionItem(R.drawable.ic_delete_tinted_24, getResources().getString(R.string.conversation_selection__menu_delete), () -> handleActionItemClicked(Action.DELETE)));
return items;
}
private void handleActionItemClicked(@NonNull Action action) {
hideInternal(new OnHideListener() {
@Override public void startHide() {
if (onHideListener != null) {
onHideListener.startHide();
}
}
@Override public void onHide() {
if (onHideListener != null) {
onHideListener.onHide();
}
if (onActionSelectedListener != null) {
onActionSelectedListener.onActionSelected(action);
}
}
});
}
private void initAnimators() {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
List<Animator> reveals = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
@@ -507,81 +792,126 @@ public final class ConversationReactionOverlay extends RelativeLayout {
})
.toList();
Animator overlayRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
overlayRevealAnim.setDuration(duration);
reveals.add(overlayRevealAnim);
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
backgroundRevealAnim.setTarget(backgroundView);
backgroundRevealAnim.setDuration(duration);
backgroundRevealAnim.setDuration(revealDuration);
backgroundRevealAnim.setStartDelay(revealOffset);
reveals.add(backgroundRevealAnim);
Animator selectedRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
selectedRevealAnim.setTarget(selectedView);
selectedRevealAnim.setDuration(duration);
backgroundRevealAnim.setDuration(revealDuration);
backgroundRevealAnim.setStartDelay(revealOffset);
reveals.add(selectedRevealAnim);
Animator toolbarRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
toolbarRevealAnim.setTarget(toolbar);
toolbarRevealAnim.setDuration(duration);
reveals.add(toolbarRevealAnim);
revealAnimatorSet.setInterpolator(INTERPOLATOR);
revealAnimatorSet.playTogether(reveals);
}
revealMaskAnimatorSet.setInterpolator(INTERPOLATOR);
revealMaskAnimatorSet.playTogether(overlayRevealAnim);
private @NonNull AnimatorSet newHideAnimatorSet() {
AnimatorSet set = new AnimatorSet();
List<Animator> hides = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
return anim;
})
.toList();
Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
overlayHideAnim.setDuration(duration);
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
backgroundHideAnim.setTarget(backgroundView);
backgroundHideAnim.setDuration(duration);
hides.add(backgroundHideAnim);
Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
selectedHideAnim.setTarget(selectedView);
selectedHideAnim.setDuration(duration);
hides.add(selectedHideAnim);
Animator toolbarHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
toolbarHideAnim.setTarget(toolbar);
toolbarHideAnim.setDuration(duration);
hides.add(toolbarHideAnim);
AnimationCompleteListener hideListener = new AnimationCompleteListener() {
set.addListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(View.GONE);
}
};
});
set.setInterpolator(INTERPOLATOR);
List<Animator> hideAllAnimators = new LinkedList<>(hides);
hideAllAnimators.add(overlayHideAnim);
set.playTogether(newHideAnimators());
hideAnimatorSet.addListener(hideListener);
hideAnimatorSet.setInterpolator(INTERPOLATOR);
hideAnimatorSet.playTogether(hideAllAnimators);
return set;
}
hideAllButMaskAnimatorSet.setInterpolator(INTERPOLATOR);
hideAllButMaskAnimatorSet.playTogether(hides);
private @NonNull List<Animator> newHideAnimators() {
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
hideMaskAnimatorSet.addListener(hideListener);
hideMaskAnimatorSet.setInterpolator(INTERPOLATOR);
hideMaskAnimatorSet.playTogether(overlayHideAnim);
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
anim.setTarget(v);
return anim;
})
.toList());
Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
overlayHideAnim.setDuration(duration);
animators.add(overlayHideAnim);
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
backgroundHideAnim.setTarget(backgroundView);
backgroundHideAnim.setDuration(duration);
animators.add(backgroundHideAnim);
Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
selectedHideAnim.setTarget(selectedView);
selectedHideAnim.setDuration(duration);
animators.add(selectedHideAnim);
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
itemScaleXAnim.setProperty(View.SCALE_X);
itemScaleXAnim.setFloatValues(1f);
itemScaleXAnim.setTarget(conversationItem);
itemScaleXAnim.setDuration(duration);
animators.add(itemScaleXAnim);
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
itemScaleYAnim.setProperty(View.SCALE_Y);
itemScaleYAnim.setFloatValues(1f);
itemScaleYAnim.setTarget(conversationItem);
itemScaleYAnim.setDuration(duration);
animators.add(itemScaleYAnim);
ObjectAnimator itemXAnim = new ObjectAnimator();
itemXAnim.setProperty(View.X);
itemXAnim.setFloatValues(selectedConversationModel.getBubbleX());
itemXAnim.setTarget(conversationItem);
itemXAnim.setDuration(duration);
animators.add(itemXAnim);
ObjectAnimator itemYAnim = new ObjectAnimator();
itemYAnim.setProperty(View.Y);
itemYAnim.setFloatValues(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
itemYAnim.setTarget(conversationItem);
itemYAnim.setDuration(duration);
animators.add(itemYAnim);
ObjectAnimator toolbarShadeAnim = new ObjectAnimator();
toolbarShadeAnim.setProperty(View.ALPHA);
toolbarShadeAnim.setFloatValues(0f);
toolbarShadeAnim.setTarget(toolbarShade);
toolbarShadeAnim.setDuration(duration);
animators.add(toolbarShadeAnim);
ObjectAnimator inputShadeAnim = new ObjectAnimator();
inputShadeAnim.setProperty(View.ALPHA);
inputShadeAnim.setFloatValues(0f);
inputShadeAnim.setTarget(inputShade);
inputShadeAnim.setDuration(duration);
animators.add(inputShadeAnim);
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
statusBarAnim.setDuration(duration);
statusBarAnim.addUpdateListener(animation -> {
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
});
animators.add(statusBarAnim);
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
navigationBarAnim.setDuration(duration);
navigationBarAnim.addUpdateListener(animation -> {
WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
});
animators.add(navigationBarAnim);
}
return animators;
}
public interface OnHideListener {
void startHide();
void onHide();
}
@@ -590,6 +920,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
}
public interface OnActionSelectedListener {
void onActionSelected(@NonNull Action action);
}
private static class Boundary {
private float min;
private float max;
@@ -621,4 +955,15 @@ public final class ConversationReactionOverlay extends RelativeLayout {
SCRUB,
TAP
}
public enum Action {
REPLY,
FORWARD,
RESEND,
DOWNLOAD,
COPY,
MULTISELECT,
VIEW_INFO,
DELETE,
}
}

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