Compare commits

..

184 Commits

Author SHA1 Message Date
Alex Hart
abde740ff7 Bump version to 5.25.6 2021-10-26 17:07:30 -03:00
Alex Hart
9efe216070 Updated language translations. 2021-10-26 17:07:30 -03:00
Alex Hart
2427c226a8 Disable message animations when scrolling. 2021-10-26 17:07:30 -03:00
Greyson Parrelli
ae73601f52 Load thumbnails using an asynchronous Glide target. 2021-10-26 17:07:30 -03:00
Alex Hart
85551ca824 Fix keyboard issue on some Android devices. 2021-10-26 17:07:30 -03:00
Alex Hart
12565d28ae Fix possible NPE. 2021-10-26 10:25:44 -03:00
Greyson Parrelli
f0a4956cdd Exclude the HeapTaskDaemon from blocked thread warnings.
It's just how the thing works in a lot of cases, and it's polluting the
logs with instances of nothing but several blocked HeapTaskDaemons.
2021-10-26 09:20:46 -04:00
Greyson Parrelli
ba0befde20 Fix issue where delivery receipts may not update the thread summary.
We were notifying in a transaction, which we can't do anymore since
transactions don't block reads from other threads (meaning we could
notify and someone could read it before we end the transaction, so they
wouldn't see the update).
2021-10-26 09:10:59 -04:00
Alex Hart
dd7652ad44 Bump version to 5.25.5 2021-10-25 15:37:49 -03:00
Alex Hart
b41303ba0d Updated language translations. 2021-10-25 15:34:39 -03:00
Greyson Parrelli
a70ab94d24 Disallow swiping on selected conversation list items. 2021-10-25 14:28:37 -04:00
Greyson Parrelli
10dd39abea Fix layout of long actionbar strings. 2021-10-25 14:26:51 -04:00
Alex Hart
5113f8b203 Ensure MP4 Gif vertical position updates as content slides. 2021-10-25 15:23:15 -03:00
Jim Gustafson
8f007a23cd Update to RingRTC v2.13.6 2021-10-25 14:15:47 -03:00
Alex Hart
b34bb2e7d7 Drastically reduce number of projection instances we create.
Via SimplePool
2021-10-25 14:12:08 -03:00
Alex Hart
98fce53cf1 Fix several beta issues with new slide animations. 2021-10-25 13:39:01 -03:00
Greyson Parrelli
ced05fe579 Fix conflict between plural and normal string keys. 2021-10-25 08:35:00 -04:00
Greyson Parrelli
fae21e4dbb Bump version to 5.25.4 2021-10-24 14:32:14 -04:00
Greyson Parrelli
5e3a3e1da9 Updated language translations. 2021-10-24 14:31:41 -04:00
Greyson Parrelli
03ad5073d2 Adjust SignalExecutors.BOUNDED config to actually use extra threads. 2021-10-24 14:19:11 -04:00
Greyson Parrelli
3bd354289d Update r8 to 3.0.73
Fixes #11352
2021-10-23 00:46:56 -04:00
Greyson Parrelli
8808526d0b Bump version to 5.25.3 2021-10-22 22:43:55 -04:00
Greyson Parrelli
0a19440ffc Updated language translations. 2021-10-22 22:42:55 -04:00
Alex Hart
9815851bb9 Fix various issues with conversation animation. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
1581a6e1cc Adjust the SignalExecutor.BOUNDED config. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
e3aa244f31 Improve logging for thumbnail timeouts. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
8fcce9fba5 Additional logging for blocked thread pools. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
7d49c77d1a Add vertical translation to the bottom actionbar animation. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
947f59e81b Improve chat list multiselect animation performance. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
7cac62f3f2 Update thread after attachment downloads. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
4578c33968 Fix avatars being clickable in multiselect. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
0160303d19 Update text for internal preference. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
31aabd9851 Fix unread count font scaling. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
7f39b9b50f Reduce thumbnail generation threshold to 1 second. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
69a2664668 Update bounded IO thread naming.
Helps with logging in DeadlockDetector.
2021-10-22 22:42:55 -04:00
Greyson Parrelli
acebf5964c Update actionbar strings to allow for pluralization. 2021-10-22 22:42:55 -04:00
Greyson Parrelli
ec2e3e29c3 Hide megaphones during multiselect. 2021-10-22 11:14:45 -04:00
Greyson Parrelli
0fc144d4a7 Bump version to 5.25.2 2021-10-22 10:46:17 -04:00
Greyson Parrelli
73025ec6de Updated language translations. 2021-10-22 10:46:17 -04:00
Greyson Parrelli
1d0e00648f Fix 30 day message duration.
Unfortunately leftover code from trying to repro a bug.
2021-10-22 10:46:17 -04:00
Greyson Parrelli
42b5654a99 Bump version to 5.25.1 2021-10-21 21:51:50 -04:00
Greyson Parrelli
2eb787d78b Updated language translations. 2021-10-21 21:51:29 -04:00
Greyson Parrelli
1249cced2d Set a timeout of 3 seconds to get a chat list thumbnail. 2021-10-21 21:32:07 -04:00
Greyson Parrelli
0be1a30766 Add the ability to mute on the chat list. 2021-10-21 21:22:19 -04:00
Greyson Parrelli
ea253a2e67 Bump version to 5.25.0 2021-10-21 17:11:46 -04:00
Greyson Parrelli
c4fadccf72 Updated language translations. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
fcf62512a7 Log when executors are full. 2021-10-21 17:11:46 -04:00
Alex Hart
16ab27084c Move multiselect animation code to decorator. 2021-10-21 17:11:46 -04:00
Alex Hart
c1820459b7 Implement further features for badges.
* Add Subscriptions API
* Add Accept-Language header to profile requests
* Fix several UI bugs, add error dialogs, etc.
2021-10-21 17:11:46 -04:00
Greyson Parrelli
d88999d6d4 Add new bottom actionbar to the media overview. 2021-10-21 17:11:46 -04:00
Alex Hart
68655194a6 Add bubble resize animation. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
f533a898f5 Add new bottom actionbar to chat list. 2021-10-21 17:11:46 -04:00
Alex Hart
2167522f7d Add sliding animation when a new message is received. 2021-10-21 17:11:46 -04:00
Robert Adam
f198b890fa Update bug report issue template.
The instructions for obtaining a debug log were not really indicating where the Debug logs can be found (nowadays).
2021-10-21 17:11:46 -04:00
Greyson Parrelli
85cb41050e Re-order error handling in GroupSendJob. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
00c131355f Log more specific database exceptions. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
13ef53372e Remove the reset session button. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
f2cf77339e Fix logging of DEM deviceId. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
3e5be2cfe2 Show a popup menu when long-pressing on the conversation list. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
c0a68202a7 Update some settings menus to use MaterialAlertDialogBuilder. 2021-10-21 17:11:46 -04:00
Alan Evans
07a6942ea8 Only copy distinct messages.
Fixes #11696
2021-10-21 17:11:46 -04:00
Jim Gustafson
41585699d2 Move device specific control to RingRTC 2021-10-21 17:11:46 -04:00
Jim Gustafson
2fcb240c2b Update to RingRTC v2.13.5 2021-10-21 17:11:46 -04:00
Alex Hart
566e981473 Catch IAE instead of checking lifecycle. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
26e04ce6d2 Update conversation list multi-select to use checkboxes. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
2e2b4e1406 Added a general test for recipient merging. 2021-10-21 17:11:46 -04:00
Greyson Parrelli
b89e08dad7 Update libsignal-client to 0.9.7 2021-10-21 17:11:46 -04:00
Greyson Parrelli
5711b8a0fa Add instrumented tests for RecipientDatabase. 2021-10-21 17:11:46 -04:00
Alex Hart
62f9f19540 Do not autoplay in video editor. 2021-10-21 17:11:46 -04:00
Alex Hart
731683ae09 Implement adjustments to conversation list items to compensate for badge placement. 2021-10-21 17:11:46 -04:00
Alex Hart
343aadcd9a Bump version to 5.24.17 2021-10-14 16:42:37 -03:00
Alex Hart
c4ad6c2992 Updated language translations. 2021-10-14 16:42:05 -03:00
Greyson Parrelli
97dd756136 Improve logging for decryption failures. 2021-10-14 13:17:35 -04:00
Greyson Parrelli
7989c40f52 fixup! Improve observer that logs blocked threads. 2021-10-14 10:57:35 -04:00
Greyson Parrelli
0749905909 Improve logging around sessions. 2021-10-13 15:29:05 -04:00
Greyson Parrelli
168481fee5 Improve observer that logs blocked threads. 2021-10-13 11:00:48 -04:00
Greyson Parrelli
7866e2e29c Bump version to 5.24.16 2021-10-13 08:57:10 -04:00
Greyson Parrelli
4eb0dca8f6 Updated language translations. 2021-10-13 08:56:42 -04:00
Alex Hart
bc54f6ca07 Fix crash in locales without a currency. 2021-10-13 09:42:16 -03:00
Greyson Parrelli
223c0c4bce Bump version to 5.24.15 2021-10-12 16:28:45 -04:00
Greyson Parrelli
b39099b84e Updated language translations. 2021-10-12 16:28:14 -04:00
Greyson Parrelli
22d6546704 Renamed EnterCodeFragment to EnterSmsCodeFragment.
I could never find the darn thing.
2021-10-12 15:45:26 -04:00
Greyson Parrelli
a7af687f8e Add tap-for-debuglog to PinRestoreEntryFragment. 2021-10-12 15:45:26 -04:00
Alex Hart
ce9cd132ec Never display badges if they are not enabled via feature flag. 2021-10-12 16:38:15 -03:00
Greyson Parrelli
62fa99e0ee Improve network reliability. 2021-10-12 15:23:46 -04:00
Alex Hart
43e4cba3d7 Implement the majority of the Donor UI. 2021-10-12 15:55:54 -03:00
Greyson Parrelli
6cbc2f684d Properly handle media validation errors. 2021-10-11 16:17:11 -04:00
Greyson Parrelli
ffc9e8caff Add additional unit tests for phone number fuzzy matching. 2021-10-11 14:20:32 -04:00
Greyson Parrelli
49c9b0acde Remove concept of V1 vs V2 fuzzy phone number results.
V1 hasn't been used in a long time. So we can just delete that code then
remove the concept of a 'v2' from the other stuff.
2021-10-11 13:25:04 -04:00
franortiz
9c6908873c Handle multiple Argentina phone formats.
Fixes #10506
2021-10-11 13:18:08 -04:00
Greyson Parrelli
528fe67db9 Fix issue where conversation list wasn't updating for sent indicators.
We needed to add (back?) notifying the conversation list when sent
status changes.
2021-10-11 12:49:55 -04:00
Greyson Parrelli
39e14e922b Include milliseconds in generated file name.
Fixes #11670
2021-10-11 11:48:11 -04:00
Greyson Parrelli
0c8b6f8ef8 Add an observer to log blocked threads. 2021-10-08 15:18:52 -04:00
Greyson Parrelli
f65de84c19 Update sender key store and MSL to be recipient-remap-safe.
The MSL is now remapped in the merge, and the sender key store is now
just keyed off of UUIDs.
2021-10-08 12:41:47 -04:00
Alex Hart
88074134af Fix case where dialog could be shown after user leaves fragment. 2021-10-07 10:45:41 -03:00
Alex Hart
b5cc570363 Gracefully handle and log when a radio list does not have a default selection. 2021-10-07 08:49:30 -03:00
Alex Hart
3cbf0933ff Fix RTL placement of play icon in quote view. 2021-10-06 13:39:42 -03:00
Alex Hart
7f9c89483f Fix reactions shade issue in new conversations. 2021-10-06 10:02:17 -03:00
Alex Hart
8ef3d3fbbf Add extra protection to image editor crop.
Adds an extra 72dp (height of radial dial) to the protection value
for crop mode. This guarantees that the image is NOT going to have
the bottom inaccessible due to overlap with the radial dial.
2021-10-06 08:56:21 -03:00
Alex Hart
c225c2b37d Check for NPE when bad data is passed to the PDUParser. 2021-10-05 11:30:01 -03:00
Alex Hart
ff76c5fca5 Fix long name jitter as voice note position updates. 2021-10-05 11:08:42 -03:00
Alex Hart
5b99f590f8 Downsize fallback photos in conversation banner. 2021-10-05 11:08:28 -03:00
Alex Hart
2d0feca278 Eliminate flicker when entering multiselect. 2021-10-05 11:08:12 -03:00
Greyson Parrelli
92e506b117 Update libsignal-client to 0.9.6 2021-10-04 21:49:59 -04:00
Greyson Parrelli
cac841d8e6 Flush logs before trimming to size.
There are situations where we may be hitting our SQLITE_BUSY timeout
when we go to trim. One possibility is that we may have a large ongoing
write when we go to trim.

So, this change just makes sure we're caught up before we go to trim,
which is the simplest thing we can do to address this. It's not a
foolproof solution though, so if we still see it crop up, we'll just
have to re-route all log operations through the single thread we have
setup in the PersistentLogger or something.
2021-10-04 21:49:59 -04:00
Greyson Parrelli
77cb9bc174 Update SQLCipher to 4.4.3-S8
This reverts commit e01381379c.
2021-10-04 21:49:59 -04:00
Cody Henthorne
309e33016a Prevent GV2 operations after becoming unregistered. 2021-10-04 21:49:59 -04:00
Jim Gustafson
938b24f623 Update to RingRTC v2.13.3 2021-10-04 21:49:59 -04:00
Cody Henthorne
82c637ef4b Add persistent sent media quality setting. 2021-10-04 21:49:59 -04:00
Alex Hart
d9e8480a12 Add donations module. 2021-10-04 21:49:59 -04:00
Greyson Parrelli
5115717f67 Show internal conversation settings for groups. 2021-10-04 21:49:59 -04:00
Greyson Parrelli
33ac48e771 Show recipient threadId in internal settings. 2021-10-04 21:49:59 -04:00
Cody Henthorne
c53f1fcecf Insert call logs for calls accepted by linked devices. 2021-10-04 21:49:59 -04:00
Greyson Parrelli
78704dce8a Add internal setting to force an emoji download. 2021-10-04 21:49:59 -04:00
Alex Hart
7f3ba1978d Add RedeemReceiptRequest object and DonationService. 2021-10-04 21:49:59 -04:00
Alex Hart
891dfc1b68 Upgrade zkgroups to 0.8.2 2021-10-04 21:49:59 -04:00
Cody Henthorne
b0ccb543d1 Update thread archive status when sending media. 2021-10-04 21:49:59 -04:00
Alex Hart
7752b3aba3 Move FiatMoney object to core-util module. 2021-10-04 21:49:59 -04:00
Cody Henthorne
0fa13eb097 Fix overlap by not inlining messages with errors. 2021-10-04 21:49:59 -04:00
Cody Henthorne
641db1cbe2 Fix navigation crashes in registration and manage profile. 2021-10-04 21:49:59 -04:00
Alex Hart
8d53c2392a Update zkgroup to v0.8.1 2021-10-04 21:49:59 -04:00
Alex Hart
8d0acb277c Add support for updated server badge image url formats. 2021-10-04 21:49:59 -04:00
Greyson Parrelli
6e00920c95 Bump version to 5.24.14 2021-10-04 21:47:58 -04:00
Greyson Parrelli
13638dc1c9 Updated language translations. 2021-10-04 21:43:05 -04:00
Greyson Parrelli
1222d020ad Fix address list for sender key messages. 2021-10-04 20:50:08 -04:00
Greyson Parrelli
d82b1ec69b Bump version to 5.24.13 2021-10-02 16:14:56 -04:00
Greyson Parrelli
8052c13526 Updated language translations. 2021-10-02 16:14:33 -04:00
Greyson Parrelli
ed8538547f Improve handling of badly-serialized data.
h/t @i-infra
2021-10-02 16:06:58 -04:00
Cody Henthorne
eb8de536e0 Bump version to 5.24.12 2021-10-01 15:29:47 -04:00
Cody Henthorne
76728c43e0 Updated language translations. 2021-10-01 15:18:26 -04:00
Alex Hart
52cfb57d36 Fix color offset on devices with notches. 2021-10-01 15:11:33 -04:00
Greyson Parrelli
a385cb0b68 Dedupe network and identity failures. 2021-10-01 15:11:33 -04:00
Greyson Parrelli
e01381379c Revert back to prod SQLCipher. 2021-10-01 15:11:33 -04:00
Cody Henthorne
d01a52c5a8 Fix truncation calculation by accounting for compound drawables. 2021-10-01 12:29:46 -04:00
Cody Henthorne
204fff1b9b Fix registration enter phone number bug. 2021-10-01 10:23:35 -04:00
Cody Henthorne
1eda1477a8 Bump version to 5.24.11 2021-09-30 14:49:16 -04:00
Cody Henthorne
3135685c0e Updated language translations. 2021-09-30 14:44:42 -04:00
Greyson Parrelli
58fdb26f04 Update emoji dataset.
Includes some previously-missing gender neutral emoji.
2021-09-30 14:29:41 -04:00
Alex Hart
9bcb1bad8e Translate message details projection to correct coordinate system. 2021-09-30 13:00:06 -03:00
Alex Hart
eb6ef3d005 Fix NPE when viewHolder has been removed from RecyclerView 2021-09-30 09:07:52 -03:00
Cody Henthorne
f40ba0bf68 Prevent starting 1:1 call with a group recipient. 2021-09-29 16:44:21 -04:00
Cody Henthorne
89df0a2c04 Fix talkback crashes on EmojiTextView. 2021-09-29 16:22:21 -04:00
Cody Henthorne
69fbd4f3fc Fix bug with autoselecting wired headset for calls. 2021-09-29 16:17:52 -04:00
Cody Henthorne
45267f3590 Bump version to 5.24.10 2021-09-29 13:30:50 -04:00
Cody Henthorne
3fb8c6eda8 Updated language translations. 2021-09-29 13:26:03 -04:00
Cody Henthorne
27ce0fd65e Fix overlapping text when message contains mixed LTR and RTL text.
Fixes #11638
2021-09-29 13:17:58 -04:00
Alex Hart
7e91132e7e Fix multiple chatcolors issues from beta feedback.
- Fix issue where custom color would come out as black
- Completely remove mask view in favour of using the item decoration.
- Fix issue where video gifs wouldn't "cut through" bubble.
- Fix issue where multiselect shade would only appear if bottom or top item was not visible
2021-09-29 13:17:58 -04:00
Cody Henthorne
705839068a Fix crash when forwarding unknown media types. 2021-09-29 13:17:57 -04:00
Alex Hart
6625ac02d5 Fix NPE when eventListener is not set. 2021-09-29 13:17:57 -04:00
Alex Hart
4b3580d98a Fix issue where mentions did not propagate in message send flow. 2021-09-29 13:17:57 -04:00
Cody Henthorne
6dbbec2631 Bump version to 5.24.9 2021-09-28 17:22:57 -04:00
Cody Henthorne
a7b6ebe7fc Updated language translations. 2021-09-28 17:19:03 -04:00
Cody Henthorne
76f52b9086 Fix various bugs around unread counts and scroll to bottom. 2021-09-28 17:12:25 -04:00
Greyson Parrelli
3310246351 Inline MP4 GIF flag.
This reverts commit 91645e6adc.
2021-09-28 17:12:25 -04:00
Alex Hart
f3d0b4a671 Fix incorrect gradient rotation. 2021-09-28 17:12:25 -04:00
Cody Henthorne
83b9fbac11 Bump version to 5.24.8 2021-09-28 11:53:40 -04:00
Cody Henthorne
5ca843825f Updated language translations. 2021-09-28 11:48:40 -04:00
rainlion
e92c83401b Fix a bug that unchanged returns true even if TransformationMethod is changed. 2021-09-28 11:42:51 -04:00
Fumiaki Yoshimatsu
e18d9e665f Take padded bytes into account when decrypting a stream of data.
Fixes #11573
2021-09-28 11:42:51 -04:00
Greyson Parrelli
cc99febe32 Allow use of the new CDSH service in staging. 2021-09-28 11:42:51 -04:00
Greyson Parrelli
e72be42eff Put SMS messages in a separate sending queue. 2021-09-28 11:42:51 -04:00
Alex Hart
bad382e2f3 Fix stretchy chat colors on Android 12. 2021-09-28 11:42:51 -04:00
Cody Henthorne
e637f15a43 Refactor call audio routing and bluetooth management. 2021-09-28 11:42:51 -04:00
Cody Henthorne
6c55916cda Fix backup restore moving forward when backgrounded. 2021-09-28 11:42:51 -04:00
Greyson Parrelli
fbabab0b70 Track down issues around empty preupload results. 2021-09-28 11:42:50 -04:00
Alex Hart
e268887255 Fix crash if animating view was removed from parent. 2021-09-28 11:42:50 -04:00
Alex Hart
6b07922757 Add error logging for media gallery objects. 2021-09-28 11:42:50 -04:00
Alex Hart
a464e57079 Fix media session reconnect issue for some devices. 2021-09-27 09:28:53 -03:00
Alex Hart
b5af691cc4 Add badges to Avatars in a variety of places. 2021-09-24 13:39:28 -03:00
Alex Hart
5c1b57e4ba Implement ExoPlayerPool for better reuse and performance. 2021-09-24 13:10:48 -03:00
Greyson Parrelli
a5c51ff801 Handle exception when reading from the log database. 2021-09-24 11:57:03 -04:00
Christelle Gloor
d755e1e29e Set onClick to entire row, not just the checkbox. 2021-09-24 11:29:59 -03:00
Alex Hart
b9361112b6 Resize the image when entering crop mode. 2021-09-24 10:58:37 -03:00
Greyson Parrelli
32101f7dda Update reaction text for GIFs. 2021-09-24 09:27:54 -04:00
Alex Hart
29e697265c Do not try to start next activity if we are not attached. 2021-09-24 09:21:09 -03:00
Alex Hart
4cd9ccc0f1 Fix crash when blocking and leaving a spam group. 2021-09-24 09:13:56 -03:00
Alex Hart
8936d81bc7 Fix 4.4 crash in image editor. 2021-09-23 17:12:14 -03:00
Alex Hart
cc36f83d77 Fix horizontal translation of video player when in multiselect mode. 2021-09-23 14:49:13 -03:00
Greyson Parrelli
64996a8db7 Register mavenLocal() repo for all projects. 2021-09-23 11:35:21 -03:00
Greyson Parrelli
7267d77dcb Add support for syncing default reactions. 2021-09-23 11:35:21 -03:00
Greyson Parrelli
2281e83607 Log RecipientId for MissingAddressErrors. 2021-09-23 11:35:21 -03:00
Alex Hart
e6b03b1a4a Implement ability to select featured badge to display on profile. 2021-09-23 11:35:21 -03:00
AsamK
fb86fdfcd9 Fix syncing reactions in note to self to linked devices.
Fixes #11027
2021-09-23 11:35:21 -03:00
Alex Hart
77cf029fdc Implement ability to view badges and modify whether they appear.
Note: this is available in staging only.
2021-09-23 11:35:21 -03:00
657 changed files with 27125 additions and 6211 deletions

View File

@@ -50,5 +50,5 @@ Describe here the issue that you are experiencing.
**Signal version:** 0.0.0
### Link to debug log
<!-- immediately after the bug has happened capture a debug log via Signal's advanced settings and paste the link below -->
<!-- immediately after the bug has happened capture a debug log via Signal's settings (Help -> Debug log) and paste the link below -->

View File

@@ -9,6 +9,7 @@ apply from: 'translations.gradle'
apply from: 'witness-verifications.gradle'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'app.cash.exhaustive'
apply plugin: 'kotlin-parcelize'
repositories {
maven {
@@ -66,8 +67,8 @@ protobuf {
}
}
def canonicalVersionCode = 927
def canonicalVersionName = "5.24.7"
def canonicalVersionCode = 944
def canonicalVersionName = "5.25.6"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -149,6 +150,7 @@ android {
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSH_URL", "\"https://cdsh.staging.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
@@ -157,13 +159,15 @@ android {
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
buildConfigField "String", "CDSH_CODE_HASH", "\"ec31a51880d19a5e9e0fed404740c1a3ff53a553125564b745acce475f0fded8\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," +
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
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 "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
@@ -174,6 +178,8 @@ android {
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\""
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
@@ -191,6 +197,11 @@ android {
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
compileOptions {
@@ -336,16 +347,17 @@ 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 "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
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 "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARB\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
}
}
@@ -451,6 +463,7 @@ dependencies {
implementation project(':video')
implementation project(':device-transfer')
implementation project(':image-editor')
implementation project(':donations')
implementation libs.signal.zkgroup.android
implementation libs.signal.client.android
@@ -541,12 +554,16 @@ dependencies {
androidTestImplementation testLibs.androidx.test.ext.junit
androidTestImplementation testLibs.espresso.core
testImplementation testLibs.espresso.core
implementation libs.kotlin.stdlib.jdk8
implementation libs.kotlin.reflect
implementation libs.jackson.module.kotlin
implementation libs.rxjava3.rxandroid
implementation libs.rxjava3.rxkotlin
androidTestUtil 'androidx.test:orchestrator:1.4.0'
}
dependencyVerification {

View File

@@ -0,0 +1,366 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.libsignal.util.guava.Optional
import java.lang.IllegalArgumentException
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest {
private lateinit var recipientDatabase: RecipientDatabase
@Before
fun setup() {
recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
ensureDbEmpty()
}
// ==============================================================
// If both the ACI and E164 map to no one
// ==============================================================
/** If all you have is an ACI, you can just store that, regardless of trust level. */
@Test
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciOnly_highTrust() {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireUuid())
assertFalse(recipient.hasE164())
}
/** If all you have is an ACI, you can just store that, regardless of trust level. */
@Test
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciOnly_lowTrust() {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, false)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireUuid())
assertFalse(recipient.hasE164())
}
/** If all you have is an E164, you can just store that, regardless of trust level. */
@Test
fun getAndPossiblyMerge_aciAndE164MapToNoOne_e164Only_highTrust() {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
val recipient = Recipient.resolved(recipientId)
assertEquals(E164_A, recipient.requireE164())
assertFalse(recipient.hasUuid())
}
/** If all you have is an E164, you can just store that, regardless of trust level. */
@Test
fun getAndPossiblyMerge_aciAndE164MapToNoOne_e164Only_lowTrust() {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, false)
val recipient = Recipient.resolved(recipientId)
assertEquals(E164_A, recipient.requireE164())
assertFalse(recipient.hasUuid())
}
/** With high trust, you can associate an ACI-e164 pair. */
@Test
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciAndE164_highTrust() {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireUuid())
assertEquals(E164_A, recipient.requireE164())
}
/** With low trust, you cannot associate an ACI-e164 pair, and therefore can only store the ACI. */
@Test
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciAndE164_lowTrust() {
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
val recipient = Recipient.resolved(recipientId)
assertEquals(ACI_A, recipient.requireUuid())
assertFalse(recipient.hasE164())
}
// ==============================================================
// If the ACI maps to an existing user, but the E164 doesn't
// ==============================================================
/** With high trust, you can associate an e164 with an existing ACI. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_highTrust() {
val existingId: RecipientId = recipientDatabase.getOrInsertFromUuid(ACI_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
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.getOrInsertFromUuid(ACI_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertFalse(retrievedRecipient.hasE164())
}
/** Basically the change number case. High trust lets you update the existing user. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2_highTrust() {
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.requireUuid())
assertEquals(E164_B, retrievedRecipient.requireE164())
}
/** Low trust means you cant update the underlying data, but you also dont need to create any new rows. */
@Test
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2_lowTrust() {
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, false)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertEquals(E164_A, retrievedRecipient.requireE164())
}
// ==============================================================
// If the E164 maps to an existing user, but the ACI doesn't
// ==============================================================
/** With high trust, you can associate an e164 with an existing ACI. */
@Test
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_highTrust() {
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertEquals(E164_A, retrievedRecipient.requireE164())
}
/** With low trust, you cannot associate an ACI-e164 pair, and therefore need to create a new person with just the ACI. */
@Test
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_lowTrust() {
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertFalse(retrievedRecipient.hasE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(E164_A, existingRecipient.requireE164())
assertFalse(existingRecipient.hasUuid())
}
/** 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)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_B, retrievedRecipient.requireUuid())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(ACI_A, existingRecipient.requireUuid())
assertFalse(existingRecipient.hasE164())
}
/** We never change the ACI of an existing row. New ACI = new person, regardless of trust. And low trust means we cant take the e164. */
@Test
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2_lowTrust() {
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, false)
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_B, retrievedRecipient.requireUuid())
assertFalse(retrievedRecipient.hasE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(ACI_A, existingRecipient.requireUuid())
assertEquals(E164_A, existingRecipient.requireE164())
}
// ==============================================================
// If both the ACI and E164 map to an existing user
// ==============================================================
/** Regardless of trust, if your ACI and e164 match, youre good. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_highTrust() {
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
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.getOrInsertFromUuid(ACI_A)
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingAciId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(existingE164Id)
assertEquals(retrievedId, existingE164Recipient.id)
}
/** 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.getOrInsertFromUuid(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.requireUuid())
assertFalse(retrievedRecipient.hasE164())
val existingE164Recipient = Recipient.resolved(existingE164Id)
assertEquals(E164_A, existingE164Recipient.requireE164())
assertFalse(existingE164Recipient.hasUuid())
}
/** 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 existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingId1, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingRecipient2 = Recipient.resolved(existingId2)
assertEquals(ACI_B, existingRecipient2.requireUuid())
assertFalse(existingRecipient2.hasE164())
}
/** Another low trust case. No new rules here, just a more complex scenario to show how different rules interact. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex_lowTrust() {
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
assertEquals(existingId1, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertEquals(E164_B, retrievedRecipient.requireE164())
val existingRecipient2 = Recipient.resolved(existingId2)
assertEquals(ACI_B, existingRecipient2.requireUuid())
assertEquals(E164_A, existingRecipient2.requireE164())
}
// ==============================================================
// Misc
// ==============================================================
@Test
fun createByE164SanityCheck() {
// GIVEN one recipient
val recipientId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
// WHEN I retrieve one by E164
val possible: Optional<RecipientId> = recipientDatabase.getByE164(E164_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.e164.isPresent)
assertEquals(E164_A, recipient.e164.get())
}
@Test
fun createByUuidSanityCheck() {
// GIVEN one recipient
val recipientId: RecipientId = recipientDatabase.getOrInsertFromUuid(ACI_A)
// WHEN I retrieve one by UUID
val possible: Optional<RecipientId> = recipientDatabase.getByUuid(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.uuid.isPresent)
assertEquals(ACI_A, recipient.uuid.get())
}
@Test(expected = IllegalArgumentException::class)
fun getAndPossiblyMerge_noArgs_invalid() {
recipientDatabase.getAndPossiblyMerge(null, null, true)
}
private val context: Application
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
private fun ensureDbEmpty() {
DatabaseFactory.getInstance(context).rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME}", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getLong(0))
}
}
companion object {
val ACI_A = UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e")
val ACI_B = UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed")
val E164_A = "+12221234567"
val E164_B = "+13331234567"
}
}

View File

@@ -0,0 +1,220 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.signal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.CursorUtil
import org.whispersystems.libsignal.IdentityKey
import org.whispersystems.libsignal.SignalProtocolAddress
import org.whispersystems.libsignal.state.SessionRecord
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_merges {
private lateinit var recipientDatabase: RecipientDatabase
private lateinit var identityDatabase: IdentityDatabase
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
private lateinit var groupDatabase: GroupDatabase
private lateinit var threadDatabase: ThreadDatabase
private lateinit var smsDatabase: MessageDatabase
private lateinit var mmsDatabase: MessageDatabase
private lateinit var sessionDatabase: SessionDatabase
private lateinit var mentionDatabase: MentionDatabase
@Before
fun setup() {
recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
identityDatabase = DatabaseFactory.getIdentityDatabase(context)
groupReceiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context)
groupDatabase = DatabaseFactory.getGroupDatabase(context)
threadDatabase = DatabaseFactory.getThreadDatabase(context)
smsDatabase = DatabaseFactory.getSmsDatabase(context)
mmsDatabase = DatabaseFactory.getMmsDatabase(context)
sessionDatabase = DatabaseFactory.getSessionDatabase(context)
mentionDatabase = DatabaseFactory.getMentionDatabase(context)
ensureDbEmpty()
}
/** 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_general() {
// Setup
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromUuid(ACI_A)
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
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
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
assertNotEquals(threadIdAci, threadIdE164)
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
val identityKeyAci: IdentityKey = identityKey(1)
val identityKeyE164: IdentityKey = identityKey(2)
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())
// Merge
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
assertEquals(recipientIdAci, retrievedId)
// Recipient validation
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireUuid())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(recipientIdE164)
assertEquals(retrievedId, existingE164Recipient.id)
// Thread validation
assertEquals(threadIdAci, retrievedThreadId)
assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
assertNull(threadDatabase.getThreadRecord(threadIdE164))
// SMS validation
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
assertEquals(retrievedId, sms1.recipient.id)
assertEquals(retrievedId, sms2.recipient.id)
assertEquals(retrievedId, sms3.recipient.id)
assertEquals(retrievedThreadId, sms1.threadId)
assertEquals(retrievedThreadId, sms2.threadId)
assertEquals(retrievedThreadId, sms3.threadId)
// MMS validation
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
assertEquals(retrievedId, mms1.recipient.id)
assertEquals(retrievedId, mms2.recipient.id)
assertEquals(retrievedId, mms3.recipient.id)
assertEquals(retrievedThreadId, mms1.threadId)
assertEquals(retrievedThreadId, mms2.threadId)
assertEquals(retrievedThreadId, mms3.threadId)
// Mention validation
val mention1: MentionModel = getMention(mmsId1)
assertEquals(retrievedId, mention1.recipientId)
assertEquals(retrievedThreadId, mention1.threadId)
val mention2: MentionModel = getMention(mmsId2)
assertEquals(retrievedId, mention2.recipientId)
assertEquals(retrievedThreadId, mention2.threadId)
// Group receipt validation
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
assertEquals(retrievedId, groupReceipts[0].recipientId)
assertEquals(retrievedId, groupReceipts[1].recipientId)
// Identity validation
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
// Session validation
assertNotNull(sessionDatabase.load(SignalProtocolAddress(ACI_A.toString(), 1)))
}
private val context: Application
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
private fun ensureDbEmpty() {
DatabaseFactory.getInstance(context).rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME}", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getLong(0))
}
}
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.absent()): IncomingTextMessage {
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
}
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.absent()): IncomingMediaMessage {
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.absent())
}
private fun identityKey(value: Byte): IdentityKey {
val bytes = ByteArray(33)
bytes[0] = 0x05
bytes[1] = value
return IdentityKey(bytes)
}
private fun groupMasterKey(value: Byte): GroupMasterKey {
val bytes = ByteArray(32)
bytes[0] = value
return GroupMasterKey(bytes)
}
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
return DecryptedGroup.newBuilder()
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
.build()
}
private fun getMention(messageId: Long): MentionModel {
val db: SQLiteDatabase = DatabaseFactory.getInstance(context).rawDatabase
db.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME}").use { cursor ->
cursor.moveToFirst()
return MentionModel(
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
)
}
}
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
data class MentionModel(
val recipientId: RecipientId,
val threadId: Long
)
companion object {
val ACI_A = UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e")
val ACI_B = UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed")
val E164_A = "+12221234567"
val E164_B = "+13331234567"
}
}

View File

@@ -1,6 +1,9 @@
package org.thoughtcrime.securesms.lock;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.thoughtcrime.securesms.util.Hex;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.whispersystems.signalservice.api.kbs.KbsData;
@@ -12,6 +15,7 @@ import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@RunWith(AndroidJUnit4.class)
public final class PinHashing_hashPin_Test {
@Test

View File

@@ -101,6 +101,10 @@
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
<meta-data
android:name="com.google.android.gms.wallet.api.enabled"
android:value="true" />
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long

View File

@@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.SubscriberIdKeepAliveListener;
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -82,8 +83,6 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.webrtc.voiceengine.WebRtcAudioManager;
import org.webrtc.voiceengine.WebRtcAudioUtils;
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
import java.security.Security;
@@ -191,8 +190,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
long startTime = System.currentTimeMillis();
Log.i(TAG, "App is now visible.");
ApplicationDependencies.getFrameRateTracker().begin();
ApplicationDependencies.getFrameRateTracker().start();
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
ApplicationDependencies.getDeadlockDetector().start();
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();
@@ -213,8 +213,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
Log.i(TAG, "App is no longer visible.");
KeyCachingService.onAppBackgrounded(this);
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
ApplicationDependencies.getFrameRateTracker().end();
ApplicationDependencies.getFrameRateTracker().stop();
ApplicationDependencies.getShakeToReport().disable();
ApplicationDependencies.getDeadlockDetector().stop();
}
public PersistentLogger getPersistentLogger() {
@@ -258,7 +259,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
SignalExecutors.UNBOUNDED.execute(() -> LogDatabase.getInstance(this).trimToSize());
SignalExecutors.UNBOUNDED.execute(() -> {
Log.blockUntilAllWritesFinished();
LogDatabase.getInstance(this).trimToSize();
});
}
private void initializeCrashHandling() {
@@ -330,6 +334,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
LocalBackupListener.schedule(this);
RotateSenderCertificateListener.schedule(this);
MessageProcessReceiver.startOrUpdateAlarm(this);
SubscriberIdKeepAliveListener.schedule(this);
if (BuildConfig.PLAY_STORE_DISABLED) {
UpdateApkRefreshListener.schedule(this);
@@ -338,14 +343,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeRingRtc() {
try {
if (RtcDeviceLists.hardwareAECBlocked()) {
WebRtcAudioUtils.setWebRtcBasedAcousticEchoCanceler(true);
}
if (!RtcDeviceLists.openSLESAllowed()) {
WebRtcAudioManager.setBlacklistDeviceForOpenSLESUsage(true);
}
CallManager.initialize(this, new RingRtcLogger());
} catch (UnsatisfiedLinkError e) {
throw new AssertionError("Unable to load ringrtc library", e);

View File

@@ -56,6 +56,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
// Intentionally Blank.
}
default void updateContactNameColor() {
// Intentionally Blank.
}
interface EventListener {
void onQuoteClicked(MmsMessageRecord messageRecord);
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
@@ -66,7 +70,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onAddToContactsClicked(@NonNull Contact contact);
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms);
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);

View File

@@ -33,22 +33,19 @@ public class MuteDialog extends AlertDialog {
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
builder.setTitle(R.string.MuteDialog_mute_notifications);
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, final int which) {
final long muteUntil;
builder.setItems(R.array.mute_durations, (dialog, which) -> {
final long muteUntil;
switch (which) {
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
case 4: muteUntil = Long.MAX_VALUE; break;
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
}
listener.onMuted(muteUntil);
switch (which) {
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
case 4: muteUntil = Long.MAX_VALUE; break;
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
}
listener.onMuted(muteUntil);
});
if (cancelListener != null) {

View File

@@ -0,0 +1,29 @@
package org.thoughtcrime.securesms
import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.annotation.ColorInt
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.security.MessageDigest
/**
* BitmapTransformation which overlays the given bitmap with the given color.
*/
class OverlayTransformation(
@ColorInt private val color: Int
) : BitmapTransformation() {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update("${OverlayTransformation::class.java.name}$color".toByteArray(CHARSET))
}
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
val outBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
canvas.drawBitmap(toTransform, 0f, 0f, null)
canvas.drawColor(color)
return outBitmap
}
}

View File

@@ -1,48 +0,0 @@
package org.thoughtcrime.securesms;
import android.os.Build;
import java.util.HashSet;
import java.util.Set;
/**
* Device hardware capability lists.
* <p>
* Moved outside of ApplicationContext as the indirection was important for API19 support with desugaring: https://issuetracker.google.com/issues/183419297
*/
final class RtcDeviceLists {
private RtcDeviceLists() {}
static Set<String> hardwareAECBlockList() {
return new HashSet<String>() {{
add("Pixel");
add("Pixel XL");
add("Moto G5");
add("Moto G (5S) Plus");
add("Moto G4");
add("TA-1053");
add("Mi A1");
add("Mi A2");
add("E5823"); // Sony z5 compact
add("Redmi Note 5");
add("FP2"); // Fairphone FP2
add("MI 5");
}};
}
static Set<String> openSlEsAllowList() {
return new HashSet<String>() {{
add("Pixel");
add("Pixel XL");
}};
}
static boolean hardwareAECBlocked() {
return hardwareAECBlockList().contains(Build.MODEL);
}
static boolean openSLESAllowed() {
return openSlEsAllowList().contains(Build.MODEL);
}
}

View File

@@ -17,8 +17,6 @@
package org.thoughtcrime.securesms;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
@@ -79,6 +77,7 @@ import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
@@ -86,6 +85,8 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
private static final String TAG = Log.tag(WebRtcCallActivity.class);
@@ -366,15 +367,15 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
private void handleSetAudioHandset() {
ApplicationDependencies.getSignalCallManager().setAudioSpeaker(false);
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.EARPIECE);
}
private void handleSetAudioSpeaker() {
ApplicationDependencies.getSignalCallManager().setAudioSpeaker(true);
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE);
}
private void handleSetAudioBluetooth() {
ApplicationDependencies.getSignalCallManager().setAudioBluetooth(true);
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.BLUETOOTH);
}
private void handleSetMuteAudio(boolean enabled) {

View File

@@ -14,7 +14,6 @@ import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import androidx.annotation.RequiresApi
import org.thoughtcrime.securesms.components.AvatarImageView
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
private const val WIDTH = "signal.circleavatartransition.width"
@@ -36,7 +35,7 @@ class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transitio
private fun captureValues(transitionValues: TransitionValues) {
val view: View = transitionValues.view
if (view is AvatarImageView) {
if (view.transitionName == "avatar") {
val topLeft = intArrayOf(0, 0)
view.getLocationOnScreen(topLeft)
transitionValues.values[POSITION_ON_SCREEN] = topLeft
@@ -51,7 +50,7 @@ class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transitio
}
val view: View = endValues.view
if (view !is AvatarImageView || view.transitionName != "avatar") {
if (view.transitionName != "avatar") {
return null
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.res.use
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.visible
import java.lang.IllegalArgumentException
class BadgeImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
private var badgeSize: Int = 0
init {
context.obtainStyledAttributes(attrs, R.styleable.BadgeImageView).use {
badgeSize = it.getInt(R.styleable.BadgeImageView_badge_size, 0)
}
}
fun setBadgeFromRecipient(recipient: Recipient?) {
if (recipient == null || recipient.badges.isEmpty()) {
setBadge(null)
} else {
setBadge(recipient.badges[0])
}
}
fun setBadge(badge: Badge?) {
visible = badge != null
try {
if (badge != null) {
GlideApp
.with(this)
.load(badge)
.downsample(DownsampleStrategy.NONE)
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), badge.imageDensity, ThemeUtil.isDarkTheme(context)))
.into(this)
} else {
GlideApp
.with(this)
.clear(this)
}
} catch (e: IllegalArgumentException) {
// Do nothing. Activity was destroyed.
}
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ProfileUtil
class BadgeRepository(context: Context) {
private val context = context.applicationContext
fun setVisibilityForAllBadges(displayBadgesOnProfile: Boolean): Completable = Completable.fromAction {
val badges = Recipient.self().badges.map { it.copy(visible = displayBadgesOnProfile) }
ProfileUtil.uploadProfileWithBadges(context, badges)
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.setBadges(Recipient.self().id, badges)
}.subscribeOn(Schedulers.io())
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
val badges = Recipient.self().badges
val reOrderedBadges = listOf(featuredBadge) + (badges - featuredBadge)
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
}.subscribeOn(Schedulers.io())
}

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
import android.net.Uri
import androidx.recyclerview.widget.RecyclerView
import com.google.android.flexbox.AlignItems
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.flexbox.JustifyContent
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.util.ScreenDensity
import org.whispersystems.libsignal.util.Pair
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import java.math.BigDecimal
import java.sql.Timestamp
object Badges {
fun DSLConfiguration.displayBadges(
context: Context,
badges: List<Badge>,
selectedBadge: Badge? = null,
fadedBadgeId: String? = null
) {
badges
.map {
Badge.Model(
badge = it,
isSelected = it == selectedBadge,
isFaded = it.id == fadedBadgeId
)
}
.forEach { customPref(it) }
val perRow = context.resources.getInteger(R.integer.badge_columns)
val empties = (perRow - (badges.size % perRow)) % perRow
repeat(empties) {
customPref(Badge.EmptyModel())
}
}
fun createLayoutManagerForGridWithBadges(context: Context): RecyclerView.LayoutManager {
val layoutManager = FlexboxLayoutManager(context)
layoutManager.flexDirection = FlexDirection.ROW
layoutManager.alignItems = AlignItems.CENTER
layoutManager.justifyContent = JustifyContent.CENTER
return layoutManager
}
private fun getBadgeImageUri(densityPath: String): Uri {
return Uri.parse(BuildConfig.BADGE_STATIC_ROOT).buildUpon()
.appendPath(densityPath)
.build()
}
private fun getBestBadgeImageUriForDevice(serviceBadge: SignalServiceProfile.Badge): Pair<Uri, String> {
val bestDensity = ScreenDensity.getBestDensityBucketForDevice()
return when (bestDensity) {
"ldpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[0]), "ldpi")
"mdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[1]), "mdpi")
"hdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[2]), "hdpi")
"xxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[4]), "xxhdpi")
"xxxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[5]), "xxxhdpi")
else -> Pair(getBadgeImageUri(serviceBadge.sprites6[3]), "xdpi")
}
}
private fun getTimestamp(bigDecimal: BigDecimal): Long {
return Timestamp(bigDecimal.toLong() * 1000).time
}
@JvmStatic
fun fromServiceBadge(serviceBadge: SignalServiceProfile.Badge): Badge {
val uriAndDensity: Pair<Uri, String> = getBestBadgeImageUriForDevice(serviceBadge)
return Badge(
serviceBadge.id,
fromCode(serviceBadge.category),
serviceBadge.name,
serviceBadge.description,
uriAndDensity.first(),
uriAndDensity.second(),
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
serviceBadge.isVisible
)
}
}

View File

@@ -0,0 +1,131 @@
package org.thoughtcrime.securesms.badges.glide
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.security.MessageDigest
/**
* Cuts out the badge of the requested size from the sprite sheet.
*/
class BadgeSpriteTransformation(
private val size: Size,
private val density: String,
private val isDarkTheme: Boolean
) : BitmapTransformation() {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update("BadgeSpriteTransformation(${size.code},$density,$isDarkTheme).$VERSION".toByteArray(CHARSET))
}
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
val outBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
val inBounds = getInBounds(density, size, isDarkTheme)
val outBounds = Rect(0, 0, outWidth, outHeight)
canvas.drawBitmap(toTransform, inBounds, outBounds, null)
return outBitmap
}
enum class Size(val code: String, val frameMap: Map<Density, FrameSet>) {
SMALL(
"small",
mapOf(
Density.LDPI to FrameSet(Frame(124, 1, 13, 13), Frame(145, 31, 13, 13)),
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
Density.HDPI to FrameSet(Frame(244, 1, 25, 25), Frame(283, 58, 25, 25)),
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
)
),
MEDIUM(
"medium",
mapOf(
Density.LDPI to FrameSet(Frame(124, 16, 19, 19), Frame(160, 31, 19, 19)),
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
Density.HDPI to FrameSet(Frame(244, 28, 37, 37), Frame(310, 58, 37, 37)),
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
)
),
LARGE(
"large",
mapOf(
Density.LDPI to FrameSet(Frame(145, 1, 28, 28), Frame(124, 46, 28, 28)),
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
Density.HDPI to FrameSet(Frame(283, 1, 55, 55), Frame(244, 85, 55, 55)),
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
)
),
XLARGE(
"xlarge",
mapOf(
Density.LDPI to FrameSet(Frame(1, 1, 121, 121), Frame(1, 1, 121, 121)),
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
Density.HDPI to FrameSet(Frame(1, 1, 241, 241), Frame(1, 1, 241, 241)),
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
)
);
companion object {
fun fromInteger(integer: Int): Size {
return when (integer) {
0 -> SMALL
1 -> MEDIUM
2 -> LARGE
3 -> XLARGE
else -> LARGE
}
}
}
}
enum class Density(val density: String) {
LDPI("ldpi"),
MDPI("mdpi"),
HDPI("hdpi"),
XHDPI("xhdpi"),
XXHDPI("xxhdpi"),
XXXHDPI("xxxhdpi")
}
data class FrameSet(val light: Frame, val dark: Frame)
data class Frame(
val x: Int,
val y: Int,
val width: Int,
val height: Int
) {
fun toBounds(): Rect {
return Rect(x, y, x + width, y + height)
}
}
companion object {
private const val VERSION = 1
private fun getDensity(density: String): Density {
return Density.values().first { it.density == density }
}
private fun getFrame(size: Size, density: Density, isDarkTheme: Boolean): Frame {
val frameSet: FrameSet = size.frameMap[density]!!
return if (isDarkTheme) frameSet.dark else frameSet.light
}
private fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
return getFrame(size, getDensity(density), isDarkTheme).toBounds()
}
}
}

View File

@@ -0,0 +1,169 @@
package org.thoughtcrime.securesms.badges.models
import android.animation.ObjectAnimator
import android.net.Uri
import android.os.Parcelable
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.bumptech.glide.load.Key
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ThemeUtil
import java.security.MessageDigest
typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
/**
* A Badge that can be collected and displayed by a user.
*/
@Parcelize
data class Badge(
val id: String,
val category: Category,
val name: String,
val description: String,
val imageUrl: Uri,
val imageDensity: String,
val expirationTimestamp: Long,
val visible: Boolean,
) : Parcelable, Key {
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis()
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(id.toByteArray(Key.CHARSET))
messageDigest.update(imageUrl.toString().toByteArray(Key.CHARSET))
messageDigest.update(imageDensity.toByteArray(Key.CHARSET))
}
fun resolveDescription(shortName: String): String {
return description.replace("{short_name}", shortName)
}
class EmptyModel : PreferenceModel<EmptyModel>() {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
}
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val name: TextView = itemView.findViewById(R.id.name)
init {
itemView.isEnabled = false
itemView.isFocusable = false
itemView.isClickable = false
itemView.visibility = View.INVISIBLE
name.text = " "
}
override fun bind(model: EmptyModel) = Unit
}
class Model(
val badge: Badge,
val isSelected: Boolean = false,
val isFaded: Boolean = false
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.badge.id == badge.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
badge == newItem.badge &&
isSelected == newItem.isSelected &&
isFaded == newItem.isFaded
}
override fun getChangePayload(newItem: Model): Any? {
return if (badge == newItem.badge && isSelected != newItem.isSelected) {
SELECTION_CHANGED
} else {
null
}
}
}
class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder<Model>(itemView) {
private val check: ImageView = itemView.findViewById(R.id.checkmark)
private val badge: ImageView = itemView.findViewById(R.id.badge)
private val name: TextView = itemView.findViewById(R.id.name)
private var checkAnimator: ObjectAnimator? = null
init {
check.isSelected = true
}
override fun bind(model: Model) {
itemView.setOnClickListener {
onBadgeClicked(model.badge, model.isSelected, model.isFaded)
}
checkAnimator?.cancel()
if (payload.isNotEmpty()) {
checkAnimator = if (model.isSelected) {
ObjectAnimator.ofFloat(check, "alpha", 1f)
} else {
ObjectAnimator.ofFloat(check, "alpha", 0f)
}
checkAnimator?.start()
return
}
badge.alpha = if (model.badge.isExpired() || model.isFaded) 0.5f else 1f
GlideApp.with(badge)
.load(model.badge)
.downsample(DownsampleStrategy.NONE)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transform(
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
)
.into(badge)
if (model.isSelected) {
check.alpha = 1f
} else {
check.alpha = 0f
}
name.text = model.badge.name
}
}
enum class Category(val code: String) {
Donor("donor"),
Other("other"),
Testing("testing"); // Will be removed before final release
companion object {
fun fromCode(code: String): Category {
return when (code) {
"donor" -> Donor
"testing" -> Testing
else -> Other
}
}
}
}
companion object {
private val SELECTION_CHANGED = Any()
fun register(mappingAdapter: MappingAdapter, onBadgeClicked: OnBadgeClicked) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view))
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
}
}
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object BadgePreview {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(SubscriptionModel::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
}
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
abstract val badge: Badge?
}
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
override fun getChangePayload(newItem: Model): Any? {
return Unit
}
}
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
return true
}
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
override fun getChangePayload(newItem: SubscriptionModel): Any? {
return Unit
}
}
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
override fun bind(model: T) {
if (payload.isEmpty()) {
avatar.setRecipient(Recipient.self())
avatar.disableQuickContact()
}
badge.setBadge(model.badge)
}
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object ExpiredBadge {
class Model(val badge: Badge) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.badge.id == badge.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && newItem.badge == badge
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badge: BadgeImageView = itemView.findViewById(R.id.expired_badge)
override fun bind(model: Model) {
badge.setBadge(model.badge)
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
data class LargeBadge(
val badge: Badge
) {
class Model(val largeBadge: LargeBadge, val shortName: String) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.largeBadge.badge.id == largeBadge.badge.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return newItem.largeBadge == largeBadge && newItem.shortName == shortName
}
}
class EmptyModel : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = true
}
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
override fun bind(model: EmptyModel) {
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
private val name: TextView = itemView.findViewById(R.id.name)
private val description: TextView = itemView.findViewById(R.id.description)
override fun bind(model: Model) {
badge.setBadge(model.largeBadge.badge)
name.text = model.largeBadge.badge.name
description.text = model.largeBadge.badge.resolveDescription(model.shortName)
}
}
companion object {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
}
}
}

View File

@@ -0,0 +1,69 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
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
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
*/
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
peekHeightPercentage = 1f
) {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredBadge.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
val badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
return configure {
customPref(ExpiredBadge.Model(badge))
sectionHeaderPref(R.string.ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired)
space(DimensionUnit.DP.toPixels(4f).toInt())
noPadTextPref(
DSLSettingsText.from(
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_s_badge_has_expired, badge.name),
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
DSLSettingsText.from(
R.string.ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting,
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
primaryButton(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber),
onClick = {
dismiss()
findNavController().navigate(R.id.action_directly_to_subscribe)
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
onClick = {
dismiss()
}
)
}
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.badges.self.featured
enum class SelectFeaturedBadgeEvent {
NO_BADGE_SELECTED,
FAILED_TO_UPDATE_PROFILE,
SAVE_SUCCESSFUL
}

View File

@@ -0,0 +1,93 @@
package org.thoughtcrime.securesms.badges.self.featured
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.Badges.displayBadges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Fragment which allows user to select one of their badges to be their "Featured" badge.
*/
class SelectFeaturedBadgeFragment : DSLSettingsFragment(
titleId = R.string.BadgesOverviewFragment__featured_badge,
layoutId = R.layout.select_featured_badge_fragment,
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
) {
private val viewModel: SelectFeaturedBadgeViewModel by viewModels(factoryProducer = { SelectFeaturedBadgeViewModel.Factory(BadgeRepository(requireContext())) })
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var scrollShadow: View
private lateinit var save: View
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
scrollShadow = view.findViewById(R.id.scroll_shadow)
super.onViewCreated(view, savedInstanceState)
save = view.findViewById(R.id.save)
save.setOnClickListener {
viewModel.save()
}
}
override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
return ToolbarShadowAnimationHelper(scrollShadow)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, isSelected, _ ->
if (!isSelected) {
viewModel.setSelectedBadge(badge)
}
}
val previewView: View = requireView().findViewById(R.id.preview)
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
when (event) {
SelectFeaturedBadgeEvent.NO_BADGE_SELECTED -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__you_must_select_a_badge, Toast.LENGTH_LONG).show()
SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.SelectFeaturedBadgeFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL -> findNavController().popBackStack()
}
}
var hasBoundPreview = false
viewModel.state.observe(viewLifecycleOwner) { state ->
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
if (hasBoundPreview) {
previewViewHolder.setPayload(listOf(Unit))
} else {
hasBoundPreview = true
}
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: SelectFeaturedBadgeState): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.SelectFeaturedBadgeFragment__select_a_badge)
displayBadges(requireContext(), state.allUnlockedBadges, state.selectedBadge)
}
}
}

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.badges.self.featured
import org.thoughtcrime.securesms.badges.models.Badge
data class SelectFeaturedBadgeState(
val stage: Stage = Stage.INIT,
val selectedBadge: Badge? = null,
val allUnlockedBadges: List<Badge> = listOf()
) {
enum class Stage {
INIT,
READY,
SAVING
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.badges.self.featured
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
private val TAG = Log.tag(SelectFeaturedBadgeViewModel::class.java)
class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : ViewModel() {
private val store = Store(SelectFeaturedBadgeState())
private val eventSubject = PublishSubject.create<SelectFeaturedBadgeEvent>()
val state: LiveData<SelectFeaturedBadgeState> = store.stateLiveData
val events: Observable<SelectFeaturedBadgeEvent> = eventSubject.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable()
init {
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
val unexpiredBadges = recipient.badges.filterNot { it.isExpired() }
state.copy(
stage = if (state.stage == SelectFeaturedBadgeState.Stage.INIT) SelectFeaturedBadgeState.Stage.READY else state.stage,
selectedBadge = unexpiredBadges.firstOrNull(),
allUnlockedBadges = unexpiredBadges
)
}
}
fun setSelectedBadge(badge: Badge) {
store.update { it.copy(selectedBadge = badge) }
}
fun save() {
val snapshot = store.state
if (snapshot.selectedBadge == null) {
eventSubject.onNext(SelectFeaturedBadgeEvent.NO_BADGE_SELECTED)
return
}
store.update { it.copy(stage = SelectFeaturedBadgeState.Stage.SAVING) }
disposables += repository.setFeaturedBadge(snapshot.selectedBadge).subscribeBy(
onComplete = {
eventSubject.onNext(SelectFeaturedBadgeEvent.SAVE_SUCCESSFUL)
},
onError = { error ->
Log.e(TAG, "Failed to update profile.", error)
eventSubject.onNext(SelectFeaturedBadgeEvent.FAILED_TO_UPDATE_PROFILE)
}
)
}
override fun onCleared() {
disposables.clear()
}
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository)))
}
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.badges.self.overview
enum class BadgesOverviewEvent {
FAILED_TO_UPDATE_PROFILE
}

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.badges.self.overview
import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.Badges.displayBadges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Fragment to allow user to manage options related to the badges they've unlocked.
*/
class BadgesOverviewFragment : DSLSettingsFragment(
titleId = R.string.ManageProfileFragment_badges,
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
) {
private val lifecycleDisposable = LifecycleDisposable()
private val viewModel: BadgesOverviewViewModel by viewModels(
factoryProducer = {
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _, isFaded ->
if (badge.isExpired() || isFaded) {
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
} else {
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
}
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.add(
viewModel.events.subscribe { event: BadgesOverviewEvent ->
when (event) {
BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE -> Toast.makeText(requireContext(), R.string.BadgesOverviewFragment__failed_to_update_profile, Toast.LENGTH_LONG).show()
}
}
)
}
private fun getConfiguration(state: BadgesOverviewState): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.BadgesOverviewFragment__my_badges)
displayBadges(
context = requireContext(),
badges = state.allUnlockedBadges,
fadedBadgeId = state.fadedBadgeId
)
switchPref(
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
isChecked = state.displayBadgesOnProfile,
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
onClick = {
viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile)
}
)
clickPref(
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge),
summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
onClick = {
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
}
)
}
}
}

View File

@@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.badges.self.overview
import org.thoughtcrime.securesms.badges.models.Badge
data class BadgesOverviewState(
val stage: Stage = Stage.INIT,
val allUnlockedBadges: List<Badge> = listOf(),
val featuredBadge: Badge? = null,
val displayBadgesOnProfile: Boolean = false,
val fadedBadgeId: String? = null
) {
val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
enum class Stage {
INIT,
READY,
UPDATING
}
}

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.badges.self.overview
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
class BadgesOverviewViewModel(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModel() {
private val store = Store(BadgesOverviewState())
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
val state: LiveData<BadgesOverviewState> = store.stateLiveData
val events: Observable<BadgesOverviewEvent> = eventSubject.observeOn(AndroidSchedulers.mainThread())
val disposables = CompositeDisposable()
init {
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
state.copy(
stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage,
allUnlockedBadges = recipient.badges,
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true,
featuredBadge = recipient.featuredBadge
)
}
disposables += Single.zip(
subscriptionsRepository.getActiveSubscription(),
subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency())
) { active, all ->
if (!active.isActive && active.activeSubscription?.willCancelAtPeriodEnd() == true) {
Optional.fromNullable<String>(all.firstOrNull { it.level == active.activeSubscription?.level }?.badge?.id)
} else {
Optional.absent()
}
}.subscribeBy { badgeId ->
store.update { it.copy(fadedBadgeId = badgeId.orNull()) }
}
}
fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
disposables += badgeRepository.setVisibilityForAllBadges(displayBadgesOnProfile)
.subscribe(
{
store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
},
{ error ->
Log.e(TAG, "Failed to update visibility.", error)
store.update { it.copy(stage = BadgesOverviewState.Stage.READY) }
eventSubject.onNext(BadgesOverviewEvent.FAILED_TO_UPDATE_PROFILE)
}
)
}
override fun onCleared() {
disposables.clear()
}
class Factory(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
}
}
}

View File

@@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.badges.view
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.button.MaterialButton
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.LargeBadge
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.visible
class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
private val viewModel: ViewBadgeViewModel by viewModels(factoryProducer = { ViewBadgeViewModel.Factory(getStartBadge(), getRecipientId(), BadgeRepository(requireContext())) })
override val peekHeightPercentage: Float = 1f
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.view_badge_bottom_sheet_dialog_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
val pager: ViewPager2 = view.findViewById(R.id.pager)
val tabs: TabLayout = view.findViewById(R.id.tab_layout)
val action: MaterialButton = view.findViewById(R.id.action)
if (getRecipientId() == Recipient.self().id) {
action.visible = false
}
val adapter = MappingAdapter()
LargeBadge.register(adapter)
pager.adapter = adapter
adapter.submitList(listOf(LargeBadge.EmptyModel()))
TabLayoutMediator(tabs, pager) { _, _ ->
}.attach()
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
if (adapter.getModel(position).map { it is LargeBadge.Model }.orElse(false)) {
viewModel.onPageSelected(position)
}
}
})
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.recipient == null || state.badgeLoadState == ViewBadgeState.LoadState.INIT) {
return@observe
}
if (state.allBadgesVisibleOnProfile.isEmpty()) {
dismissAllowingStateLoss()
}
tabs.visible = state.allBadgesVisibleOnProfile.size > 1
adapter.submitList(
state.allBadgesVisibleOnProfile.map {
LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext()))
}
) {
val stateSelectedIndex = state.allBadgesVisibleOnProfile.indexOf(state.selectedBadge)
if (state.selectedBadge != null && pager.currentItem != stateSelectedIndex) {
pager.currentItem = stateSelectedIndex
}
}
}
}
private fun getStartBadge(): Badge? = requireArguments().getParcelable(ARG_START_BADGE)
private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelable(ARG_RECIPIENT_ID))
companion object {
private const val ARG_START_BADGE = "start_badge"
private const val ARG_RECIPIENT_ID = "recipient_id"
@JvmStatic
fun show(
fragmentManager: FragmentManager,
recipientId: RecipientId,
startBadge: Badge? = null
) {
ViewBadgeBottomSheetDialogFragment().apply {
arguments = Bundle().apply {
putParcelable(ARG_START_BADGE, startBadge)
putParcelable(ARG_RECIPIENT_ID, recipientId)
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.badges.view
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
data class ViewBadgeState(
val allBadgesVisibleOnProfile: List<Badge> = listOf(),
val badgeLoadState: LoadState = LoadState.INIT,
val selectedBadge: Badge? = null,
val recipient: Recipient? = null
) {
enum class LoadState {
INIT,
LOADED
}
}

View File

@@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.badges.view
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
class ViewBadgeViewModel(
private val startBadge: Badge?,
private val recipientId: RecipientId,
private val repository: BadgeRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
private val store = Store(ViewBadgeState())
val state: LiveData<ViewBadgeState> = store.stateLiveData
init {
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
state.copy(
recipient = recipient,
allBadgesVisibleOnProfile = recipient.badges,
selectedBadge = startBadge ?: recipient.badges.firstOrNull(),
badgeLoadState = ViewBadgeState.LoadState.LOADED
)
}
}
override fun onCleared() {
disposables.clear()
}
fun onPageSelected(position: Int) {
if (position > store.state.allBadgesVisibleOnProfile.size - 1 || position < 0) {
return
}
store.update {
it.copy(selectedBadge = it.allBadgesVisibleOnProfile[position])
}
}
class Factory(
private val startBadge: Badge?,
private val recipientId: RecipientId,
private val repository: BadgeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository)))
}
}
}

View File

@@ -10,6 +10,7 @@ import android.graphics.PorterDuffColorFilter;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
@@ -207,9 +208,9 @@ public class ConversationItemFooter extends ConstraintLayout {
setBackground(null);
}
public @Nullable Projection getProjection() {
public @Nullable Projection getProjection(@NonNull ViewGroup coordinateRoot) {
if (getVisibility() == VISIBLE) {
return Projection.relativeToViewRoot(this, new Projection.Corners(ViewUtil.dpToPx(11)));
return Projection.relativeToParent(coordinateRoot, this, new Projection.Corners(ViewUtil.dpToPx(11)));
} else {
return null;
}

View File

@@ -1,152 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class MaskView extends View {
private MaskTarget maskTarget;
private ViewGroup activityContentView;
private Paint maskPaint;
private Rect drawingRect = new Rect();
private float targetParentTranslationY;
private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate;
public MaskView(@NonNull Context context) {
super(context);
}
public MaskView(@NonNull Context context, @Nullable AttributeSet attributeSet) {
super(context, attributeSet);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
setLayerType(LAYER_TYPE_HARDWARE, maskPaint);
maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
activityContentView = getRootView().findViewById(android.R.id.content);
}
public void setTarget(@Nullable MaskTarget maskTarget) {
if (this.maskTarget != null) {
removeOnDrawListener(this.maskTarget, onDrawListener);
}
this.maskTarget = maskTarget;
if (this.maskTarget != null) {
addOnDrawListener(maskTarget, onDrawListener);
}
invalidate();
}
public void setTargetParentTranslationY(float targetParentTranslationY) {
this.targetParentTranslationY = targetParentTranslationY;
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
super.onDraw(canvas);
if (nothingToMask(maskTarget)) {
return;
}
maskTarget.getPrimaryTarget().getDrawingRect(drawingRect);
activityContentView.offsetDescendantRectToMyCoords(maskTarget.getPrimaryTarget(), drawingRect);
drawingRect.top += targetParentTranslationY;
drawingRect.bottom += targetParentTranslationY;
Bitmap mask = Bitmap.createBitmap(maskTarget.getPrimaryTarget().getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888);
Canvas maskCanvas = new Canvas(mask);
maskTarget.draw(maskCanvas);
canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom()));
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) maskTarget.getPrimaryTarget().getLayoutParams();
canvas.drawBitmap(mask, params.leftMargin, drawingRect.top, maskPaint);
mask.recycle();
}
private static void removeOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
for (View view : maskTarget.getAllTargets()) {
if (view != null) {
view.getViewTreeObserver().removeOnDrawListener(onDrawListener);
}
}
}
private static void addOnDrawListener(@NonNull MaskTarget maskTarget, @NonNull ViewTreeObserver.OnDrawListener onDrawListener) {
for (View view : maskTarget.getAllTargets()) {
if (view != null) {
view.getViewTreeObserver().addOnDrawListener(onDrawListener);
}
}
}
private static boolean nothingToMask(@Nullable MaskTarget maskTarget) {
if (maskTarget == null) {
return true;
}
for (View view : maskTarget.getAllTargets()) {
if (view == null || !view.isAttachedToWindow()) {
return true;
}
}
return false;
}
public static class MaskTarget {
private final View primaryTarget;
public MaskTarget(@NonNull View primaryTarget) {
this.primaryTarget = primaryTarget;
}
final @NonNull View getPrimaryTarget() {
return primaryTarget;
}
protected @NonNull List<View> getAllTargets() {
return Collections.singletonList(primaryTarget);
}
protected void draw(@NonNull Canvas canvas) {
primaryTarget.draw(canvas);
}
}
}

View File

@@ -8,6 +8,7 @@ import android.graphics.PixelFormat;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.Xfermode;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
@@ -100,6 +101,10 @@ public final class RotatableGradientDrawable extends Drawable {
fillPaint.setShader(new LinearGradient(fillRect.left, fillRect.top, fillRect.right, fillRect.bottom, colors, positions, Shader.TileMode.CLAMP));
}
public void setXfermode(@NonNull Xfermode xfermode) {
fillPaint.setXfermode(xfermode);
}
private static Point cornerPrime(@NonNull Point origin, @NonNull Point corner, float degrees) {
return new Point(xPrime(origin, corner, Math.toRadians(degrees)), yPrime(origin, corner, Math.toRadians(degrees)));
}

View File

@@ -439,7 +439,7 @@ public class ThumbnailView extends FrameLayout {
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transition(withCrossFade()), fit);
boolean doNotShowMissingThumbnailImage = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation() == 0;
boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
if (slide.isInProgress() || doNotShowMissingThumbnailImage) return request;
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));

View File

@@ -133,7 +133,7 @@ public class EmojiTextView extends AppCompatTextView {
previousTransformationMethod = getTransformationMethod();
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.NORMAL);
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.SPANNABLE);
} else {
CharSequence emojified = EmojiProvider.emojify(candidates, text, this);
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
@@ -165,10 +165,9 @@ public class EmojiTextView extends AppCompatTextView {
int lines = layout.getLineCount();
int start = layout.getLineStart(lines - 1);
int count = text.length() - start;
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, start, count)) ||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, start, count))) {
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, 0, text.length())) ||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length()))) {
lastLineWidth = getMeasuredWidth();
} else {
lastLineWidth = (int) getPaint().measureText(text, start, text.length());
@@ -220,7 +219,7 @@ public class EmojiTextView extends AppCompatTextView {
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
super.setText(newContent, BufferType.NORMAL);
super.setText(newContent, BufferType.SPANNABLE);
} else {
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
super.setText(emojified, BufferType.SPANNABLE);
@@ -266,7 +265,7 @@ public class EmojiTextView extends AppCompatTextView {
Util.equals(previousBufferType, bufferType) &&
useSystemEmoji == useSystemEmoji() &&
!sizeChangeInProgress &&
previousTransformationMethod != getTransformationMethod();
previousTransformationMethod == getTransformationMethod();
}
private boolean useSystemEmoji() {

View File

@@ -5,6 +5,7 @@ import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.whispersystems.libsignal.util.guava.Optional
open class SimpleEmojiTextView @JvmOverloads constructor(
@@ -14,17 +15,22 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var bufferType: BufferType? = null
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
override fun setText(text: CharSequence?, type: BufferType?) {
bufferType = type
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
super.setText(Optional.fromNullable(text).or(""), type)
} else {
val startDrawableSize: Int = compoundDrawables[0]?.let { it.intrinsicWidth + compoundDrawablePadding } ?: 0
val endDrawableSize: Int = compoundDrawables[1]?.let { it.intrinsicWidth + compoundDrawablePadding } ?: 0
val adjustedWidth: Int = width - startDrawableSize - endDrawableSize
val newContent = if (width == 0 || maxLines == -1) {
text
} else {
TextUtils.ellipsize(text, paint, (width * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
TextUtils.ellipsize(text, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
}
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
@@ -40,8 +46,10 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
if (width > 0 && oldWidth != width) {
setText(text, bufferType ?: BufferType.NORMAL)
sizeChangeDebouncer.publish {
if (width > 0 && oldWidth != width) {
setText(text, bufferType ?: BufferType.SPANNABLE)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.components.menu
import androidx.annotation.DrawableRes
/**
* Represents an action to be rendered via [SignalContextMenu] or [SignalBottomActionBar]
*/
data class ActionItem(
@DrawableRes val iconRes: Int,
val title: CharSequence,
val action: Runnable
)

View File

@@ -0,0 +1,109 @@
package org.thoughtcrime.securesms.components.menu
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
/**
* A bar that displays a set of action buttons. Intended as a replacement for ActionModes, this gives you a simple interface to add a bunch of actions, and
* the bar itself will handle putting things in the overflow and whatnot.
*
* Overflow items are rendered in a [SignalContextMenu].
*/
class SignalBottomActionBar(context: Context, attributeSet: AttributeSet) : LinearLayout(context, attributeSet) {
val items: MutableList<ActionItem> = mutableListOf()
val enterAnimation: Animation by lazy {
AnimationUtils.loadAnimation(context, R.anim.slide_fade_from_bottom).apply {
duration = 250
interpolator = FastOutSlowInInterpolator()
}
}
val exitAnimation: Animation by lazy {
AnimationUtils.loadAnimation(context, R.anim.slide_fade_to_bottom).apply {
duration = 250
interpolator = FastOutSlowInInterpolator()
}
}
init {
orientation = HORIZONTAL
setBackgroundResource(R.drawable.signal_bottom_action_bar_background)
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
}
fun setItems(items: List<ActionItem>) {
this.items.clear()
this.items.addAll(items)
present(this.items)
}
private fun present(items: List<ActionItem>) {
if (width == 0) {
post { present(items) }
return
}
val widthDp: Float = ViewUtil.pxToDp(width.toFloat())
val minButtonWidthDp = 70
val maxButtons: Int = (widthDp / minButtonWidthDp).toInt()
val usableButtonCount = when {
items.size <= maxButtons -> items.size
else -> maxButtons - 1
}
val renderableItems: List<ActionItem> = items.subList(0, usableButtonCount)
val overflowItems: List<ActionItem> = if (renderableItems.size < items.size) items.subList(usableButtonCount, items.size) else emptyList()
removeAllViews()
renderableItems.forEach { item ->
val view: View = LayoutInflater.from(context).inflate(R.layout.signal_bottom_action_bar_item, this, false)
addView(view)
bindItem(view, item)
}
if (overflowItems.isNotEmpty()) {
val view: View = LayoutInflater.from(context).inflate(R.layout.signal_bottom_action_bar_item, this, false)
addView(view)
bindItem(
view,
ActionItem(
iconRes = R.drawable.ic_more_horiz_24,
title = context.getString(R.string.SignalBottomActionBar_more),
action = {
SignalContextMenu.Builder(view, parent as ViewGroup)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.END)
.offsetY(ViewUtil.dpToPx(8))
.show(overflowItems)
}
)
)
}
}
private fun bindItem(view: View, item: ActionItem) {
val icon: ImageView = view.findViewById(R.id.signal_bottom_action_bar_item_icon)
val title: TextView = view.findViewById(R.id.signal_bottom_action_bar_item_title)
icon.setImageResource(item.iconRes)
title.text = item.title
view.setOnClickListener { item.action.run() }
}
}

View File

@@ -0,0 +1,233 @@
package org.thoughtcrime.securesms.components.menu
import android.content.Context
import android.graphics.Rect
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.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
/**
* 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.
*
* This will prefer showing the menu underneath the anchor, but if there's not enough space in the container, it will show it above the anchor and reverse the
* order of the menu items. If there's not enough room for either, it'll show it centered above the anchor. If there's not enough room then, it'll center it,
* chop off the part that doesn't fit, and make the menu scrollable.
*/
class SignalContextMenu private constructor(
val anchor: View,
val container: ViewGroup,
val items: List<ActionItem>,
val baseOffsetX: Int = 0,
val baseOffsetY: Int = 0,
val horizontalPosition: HorizontalPosition = HorizontalPosition.START,
val onDismiss: Runnable? = null
) : 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
val mappingAdapter = MappingAdapter().apply {
registerFactory(DisplayItem::class.java, ItemViewHolderFactory())
}
init {
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
isFocusable = true
if (onDismiss != null) {
setOnDismissListener { onDismiss.run() }
}
if (Build.VERSION.SDK_INT >= 21) {
elevation = 20f
}
contentView.findViewById<RecyclerView>(R.id.signal_context_menu_list).apply {
adapter = mappingAdapter
layoutManager = LinearLayoutManager(context)
itemAnimator = null
}
mappingAdapter.submitList(items.toAdapterItems())
}
private fun show() {
if (anchor.width == 0 || anchor.height == 0) {
anchor.post(this::show)
return
}
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val anchorRect = Rect(anchor.left, anchor.top, anchor.right, anchor.bottom).also {
if (anchor.parent != container) {
container.offsetDescendantRectToMyCoords(anchor, it)
}
}
val menuBottomBound = anchorRect.bottom + contentView.measuredHeight + baseOffsetY
val menuTopBound = anchorRect.top - contentView.measuredHeight - baseOffsetY
val screenBottomBound = container.height
val screenTopBound = container.y
val offsetY: Int
if (menuBottomBound < screenBottomBound) {
offsetY = baseOffsetY
} else if (menuTopBound > screenTopBound) {
offsetY = -(anchorRect.height() + contentView.measuredHeight + baseOffsetY)
mappingAdapter.submitList(items.reversed().toAdapterItems())
} else {
offsetY = -((anchorRect.height() / 2) + (contentView.measuredHeight / 2) + baseOffsetY)
}
val offsetX: Int = when (horizontalPosition) {
HorizontalPosition.START -> {
if (ViewUtil.isLtr(context)) {
baseOffsetX
} else {
-(baseOffsetX + contentView.measuredWidth)
}
}
HorizontalPosition.END -> {
if (ViewUtil.isLtr(context)) {
-(baseOffsetX + contentView.measuredWidth - anchorRect.width())
} else {
baseOffsetX - anchorRect.width()
}
}
}
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 : MappingAdapter.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
}
/**
* @param anchor The view to put the pop-up on
* @param container A parent of [anchor] that represents the acceptable boundaries of the popup
*/
class Builder(
val anchor: View,
val container: ViewGroup
) {
var onDismiss: Runnable? = null
var offsetX = 0
var offsetY = 0
var horizontalPosition = HorizontalPosition.START
fun onDismiss(onDismiss: Runnable): Builder {
this.onDismiss = onDismiss
return this
}
fun offsetX(offsetPx: Int): Builder {
this.offsetX = offsetPx
return this
}
fun offsetY(offsetPx: Int): Builder {
this.offsetY = offsetPx
return this
}
fun preferredHorizontalPosition(horizontalPosition: HorizontalPosition): Builder {
this.horizontalPosition = horizontalPosition
return this
}
fun show(items: List<ActionItem>) {
SignalContextMenu(
anchor = anchor,
container = container,
items = items,
baseOffsetX = offsetX,
baseOffsetY = offsetY,
horizontalPosition = horizontalPosition,
onDismiss = onDismiss
).show()
}
}
}

View File

@@ -11,7 +11,11 @@ import androidx.annotation.CallSuper
import androidx.core.content.ContextCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.models.Button
import org.thoughtcrime.securesms.components.settings.models.Space
import org.thoughtcrime.securesms.components.settings.models.Text
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
@@ -30,6 +34,9 @@ class DSLSettingsAdapter : MappingAdapter() {
registerFactory(SectionHeaderPreference::class.java, LayoutFactory(::SectionHeaderPreferenceViewHolder, R.layout.dsl_section_header))
registerFactory(SwitchPreference::class.java, LayoutFactory(::SwitchPreferenceViewHolder, R.layout.dsl_switch_preference_item))
registerFactory(RadioPreference::class.java, LayoutFactory(::RadioPreferenceViewHolder, R.layout.dsl_radio_preference_item))
Text.register(this)
Space.register(this)
Button.register(this)
}
}
@@ -97,8 +104,13 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<Radio
override fun bind(model: RadioListPreference) {
super.bind(model)
summaryView.visibility = View.VISIBLE
summaryView.text = model.listItems[model.selected]
if (model.selected >= 0) {
summaryView.visibility = View.VISIBLE
summaryView.text = model.listItems[model.selected]
} else {
summaryView.visibility = View.GONE
Log.w(TAG, "Detected a radio list without a default selection: ${model.dialogTitle}")
}
itemView.setOnClickListener {
var selection = -1
@@ -128,6 +140,10 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<Radio
}
}
}
companion object {
private val TAG = Log.tag(RadioListPreference::class.java)
}
}
class MultiSelectListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<MultiSelectListPreference>(itemView) {

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EdgeEffect
import androidx.annotation.LayoutRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
abstract class DSLSettingsBottomSheetFragment(
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },
override val peekHeightPercentage: Float = 1f
) : FixedRoundedCornerBottomSheetDialogFragment() {
private lateinit var recyclerView: RecyclerView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(layoutId, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
recyclerView = view.findViewById(R.id.recycler)
recyclerView.edgeEffectFactory = EdgeEffectFactory()
val adapter = DSLSettingsAdapter()
recyclerView.layoutManager = layoutManagerProducer(requireContext())
recyclerView.adapter = adapter
bindAdapter(adapter)
}
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
private class EdgeEffectFactory : RecyclerView.EdgeEffectFactory() {
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
return super.createEdgeEffect(view, direction).apply {
if (Build.VERSION.SDK_INT > 21) {
color =
requireNotNull(ContextCompat.getColor(view.context, R.color.settings_ripple_color))
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.View
@@ -10,6 +11,7 @@ import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
@@ -18,7 +20,8 @@ import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimation
abstract class DSLSettingsFragment(
@StringRes private val titleId: Int = -1,
@MenuRes private val menuId: Int = -1,
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment,
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
) : Fragment(layoutId) {
private lateinit var recyclerView: RecyclerView
@@ -46,6 +49,7 @@ abstract class DSLSettingsFragment(
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
val adapter = DSLSettingsAdapter()
recyclerView.layoutManager = layoutManagerProducer(requireContext())
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(scrollAnimationHelper)

View File

@@ -3,35 +3,71 @@ package org.thoughtcrime.securesms.components.settings
import android.content.Context
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.SpanUtil
sealed class DSLSettingsText {
protected abstract val modifiers: List<Modifier>
private data class FromResource(
@StringRes private val stringId: Int,
@ColorInt private val textColor: Int?
override val modifiers: List<Modifier>
) : DSLSettingsText() {
override fun resolve(context: Context): CharSequence {
val text = context.getString(stringId)
return if (textColor == null) {
text
} else {
SpanUtil.color(textColor, text)
}
override fun getCharSequence(context: Context): CharSequence {
return context.getString(stringId)
}
}
private data class FromCharSequence(private val charSequence: CharSequence) : DSLSettingsText() {
override fun resolve(context: Context): CharSequence = charSequence
private data class FromCharSequence(
private val charSequence: CharSequence,
override val modifiers: List<Modifier>
) : DSLSettingsText() {
override fun getCharSequence(context: Context): CharSequence = charSequence
}
abstract fun resolve(context: Context): CharSequence
protected abstract fun getCharSequence(context: Context): CharSequence
fun resolve(context: Context): CharSequence {
val text: CharSequence = getCharSequence(context)
return modifiers.fold(text) { t, m -> m.modify(context, t) }
}
companion object {
fun from(@StringRes stringId: Int, @ColorInt textColor: Int? = null): DSLSettingsText =
FromResource(stringId, textColor)
fun from(@StringRes stringId: Int, @ColorInt textColor: Int): DSLSettingsText =
FromResource(stringId, listOf(ColorModifier(textColor)))
fun from(charSequence: CharSequence): DSLSettingsText = FromCharSequence(charSequence)
fun from(@StringRes stringId: Int, vararg modifiers: Modifier): DSLSettingsText =
FromResource(stringId, modifiers.toList())
fun from(charSequence: CharSequence, vararg modifiers: Modifier): DSLSettingsText =
FromCharSequence(charSequence, modifiers.toList())
}
interface Modifier {
fun modify(context: Context, charSequence: CharSequence): CharSequence
}
class ColorModifier(@ColorInt private val textColor: Int) : Modifier {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.color(textColor, charSequence)
}
}
object CenterModifier : Modifier {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.center(charSequence)
}
}
object Title2BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Title2_Bold)
object Body1Modifier : TextAppearanceModifier(R.style.Signal_Text_Body)
object Body1BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Body1_Bold)
open class TextAppearanceModifier(@StyleRes private val textAppearance: Int) : Modifier {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.textAppearance(context, textAppearance, charSequence)
}
}
}

View File

@@ -3,16 +3,24 @@ package org.thoughtcrime.securesms.components.settings.app
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.navigation.NavDirections
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
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.boost.BoostRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.FeatureFlags
private const val START_LOCATION = "app.settings.start.location"
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
@@ -22,7 +30,22 @@ class AppSettingsActivity : DSLSettingsActivity() {
private var wasConfigurationUpdated = false
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
private val subscribeViewModel: SubscribeViewModel by viewModels(
factoryProducer = {
SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
}
)
private val boostViewModel: BoostViewModel by viewModels(
factoryProducer = {
BoostViewModel.Factory(BoostRepository(), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
}
)
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
warmDonationViewModels()
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
}
@@ -40,6 +63,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions()
}
}
@@ -79,8 +103,17 @@ class AppSettingsActivity : DSLSettingsActivity() {
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
subscribeViewModel.onActivityResult(requestCode, resultCode, data)
boostViewModel.onActivityResult(requestCode, resultCode, data)
}
companion object {
private const val FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE = 1000
private const val FETCH_BOOST_TOKEN_REQUEST_CODE = 2000
@JvmStatic
fun home(context: Context): Intent = getIntentForStartLocation(context, StartLocation.HOME)
@@ -102,6 +135,9 @@ class AppSettingsActivity : DSLSettingsActivity() {
@JvmStatic
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
@JvmStatic
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.SUBSCRIPTIONS)
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
@@ -109,13 +145,21 @@ class AppSettingsActivity : DSLSettingsActivity() {
}
}
private fun warmDonationViewModels() {
if (FeatureFlags.donorBadges()) {
subscribeViewModel
boostViewModel
}
}
private enum class StartLocation(val code: Int) {
HOME(0),
BACKUPS(1),
HELP(2),
PROXY(3),
NOTIFICATIONS(4),
CHANGE_NUMBER(5);
CHANGE_NUMBER(5),
SUBSCRIPTIONS(6);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -2,9 +2,11 @@ package org.thoughtcrime.securesms.components.settings.app
import android.view.View
import android.widget.TextView
import androidx.lifecycle.ViewModelProvider
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -13,7 +15,9 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
@@ -23,17 +27,27 @@ import org.thoughtcrime.securesms.util.MappingViewHolder
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
private val viewModel: AppSettingsViewModel by viewModels(
factoryProducer = {
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(BioPreference::class.java, MappingAdapter.LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
adapter.registerFactory(PaymentsPreference::class.java, MappingAdapter.LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
val viewModel = ViewModelProvider(this)[AppSettingsViewModel::class.java]
adapter.registerFactory(SubscriptionPreference::class.java, MappingAdapter.LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item))
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
override fun onResume() {
super.onResume()
viewModel.refreshActiveSubscription()
}
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
return configure {
@@ -129,11 +143,36 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
if (FeatureFlags.donorBadges()) {
customPref(
SubscriptionPreference(
title = DSLSettingsText.from(R.string.preferences__subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
isActive = state.hasActiveSubscription,
onClick = { isActive ->
findNavController()
.navigate(
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
.setSkipToSubscribe(!isActive)
)
}
)
)
// TODO [alex] -- clap
clickPref(
title = DSLSettingsText.from(R.string.preferences__signal_boost),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
onClick = {
findNavController().navigate(R.id.action_appSettingsFragment_to_boostsFragment)
}
)
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
}
if (FeatureFlags.internalUser()) {
dividerPref()
@@ -148,6 +187,29 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
}
private class SubscriptionPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText? = null,
override val icon: DSLSettingsIcon? = null,
override val isEnabled: Boolean = true,
val isActive: Boolean = false,
val onClick: (Boolean) -> Unit
) : PreferenceModel<SubscriptionPreference>() {
override fun areItemsTheSame(newItem: SubscriptionPreference): Boolean {
return true
}
override fun areContentsTheSame(newItem: SubscriptionPreference): Boolean {
return super.areContentsTheSame(newItem) && isActive == newItem.isActive
}
}
private class SubscriptionPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SubscriptionPreference>(itemView) {
override fun bind(model: SubscriptionPreference) {
super.bind(model)
itemView.setOnClickListener { model.onClick(model.isActive) }
}
}
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
override fun areContentsTheSame(newItem: BioPreference): Boolean {
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
@@ -162,6 +224,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
private val aboutView: TextView = itemView.findViewById(R.id.about)
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
override fun bind(model: BioPreference) {
super.bind(model)
@@ -171,6 +234,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
titleView.text = model.recipient.profileName.toString()
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
avatarView.setRecipient(Recipient.self())
badgeView.setBadgeFromRecipient(Recipient.self())
titleView.visibility = View.VISIBLE
summaryView.visibility = View.VISIBLE

View File

@@ -2,4 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app
import org.thoughtcrime.securesms.recipients.Recipient
data class AppSettingsState(val self: Recipient, val unreadPaymentsCount: Int)
data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
val hasActiveSubscription: Boolean
)

View File

@@ -2,18 +2,47 @@ package org.thoughtcrime.securesms.components.settings.app
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.concurrent.TimeUnit
class AppSettingsViewModel : ViewModel() {
class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
val unreadPaymentsLiveData = UnreadPaymentsLiveData()
val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
private val store = Store(AppSettingsState(Recipient.self(), 0, false))
val state: LiveData<AppSettingsState> = LiveDataUtil.combineLatest(unreadPaymentsLiveData, selfLiveData) { payments, self ->
val unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
AppSettingsState(self, unreadPaymentsCount)
val state: LiveData<AppSettingsState> = store.stateLiveData
init {
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)) }
store.update(selfLiveData) { self, state -> state.copy(self = self) }
}
fun refreshActiveSubscription() {
if (!FeatureFlags.donorBadges()) {
return
}
store.update {
it.copy(hasActiveSubscription = TimeUnit.SECONDS.toMillis(SignalStore.donationsValues().getLastEndOfPeriod()) > System.currentTimeMillis())
}
subscriptionsRepository.getActiveSubscription().subscribeBy(
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.isActive) } },
)
}
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
}
}
}

View File

@@ -10,9 +10,9 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.fragments.BaseEnterCodeFragment
import org.thoughtcrime.securesms.registration.fragments.BaseEnterSmsCodeFragment
class ChangeNumberEnterCodeFragment : BaseEnterCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
class ChangeNumberEnterSmsCodeFragment : BaseEnterSmsCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -54,10 +54,10 @@ class ChangeNumberEnterCodeFragment : BaseEnterCodeFragment<ChangeNumberViewMode
}
override fun navigateToRegistrationLock(timeRemaining: Long) {
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
findNavController().navigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
}
override fun navigateToKbsAccountLocked() {
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
findNavController().navigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
}
}

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.webrtc.CallBandwidthMode
import kotlin.math.abs
@@ -18,6 +19,8 @@ class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences_
private val autoDownloadValues by lazy { resources.getStringArray(R.array.pref_media_download_entries) }
private val autoDownloadLabels by lazy { resources.getStringArray(R.array.pref_media_download_values) }
private val sentMediaQualityLabels by lazy { SentMediaQuality.getLabels(requireContext()) }
private val callBandwidthLabels by lazy { resources.getStringArray(R.array.pref_data_and_storage_call_bandwidth_values) }
private lateinit var viewModel: DataAndStorageSettingsViewModel
@@ -84,6 +87,21 @@ class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences_
dividerPref()
sectionHeaderPref(R.string.DataAndStorageSettingsFragment__media_quality)
radioListPref(
title = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sent_media_quality),
listItems = sentMediaQualityLabels,
selected = SentMediaQuality.values().indexOf(state.sentMediaQuality),
onSelected = { viewModel.setSentMediaQuality(SentMediaQuality.values()[it]) }
)
textPref(
summary = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sending_high_quality_media_will_use_more_data)
)
dividerPref()
sectionHeaderPref(R.string.DataAndStorageSettingsFragment__calls)
radioListPref(

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.data
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.webrtc.CallBandwidthMode
data class DataAndStorageSettingsState(
@@ -8,5 +9,6 @@ data class DataAndStorageSettingsState(
val wifiAutoDownloadValues: Set<String>,
val roamingAutoDownloadValues: Set<String>,
val callBandwidthMode: CallBandwidthMode,
val isProxyEnabled: Boolean
val isProxyEnabled: Boolean,
val sentMediaQuality: SentMediaQuality
)

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.webrtc.CallBandwidthMode
@@ -46,6 +47,11 @@ class DataAndStorageSettingsViewModel(
getStateAndCopyStorageUsage()
}
fun setSentMediaQuality(sentMediaQuality: SentMediaQuality) {
SignalStore.settings().sentMediaQuality = sentMediaQuality
getStateAndCopyStorageUsage()
}
private fun getStateAndCopyStorageUsage() {
store.update { getState().copy(totalStorageUse = it.totalStorageUse) }
}
@@ -62,7 +68,8 @@ class DataAndStorageSettingsViewModel(
ApplicationDependencies.getApplication()
),
callBandwidthMode = SignalStore.settings().callBandwidthMode,
isProxyEnabled = SignalStore.proxy().isProxyEnabled
isProxyEnabled = SignalStore.proxy().isProxyEnabled,
sentMediaQuality = SignalStore.settings().sentMediaQuality
)
class Factory(

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
@@ -234,6 +235,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_force_emoji_download),
summary = DSLSettingsText.from(R.string.preferences__internal_force_emoji_download_description),
onClick = {
ApplicationDependencies.getJobManager().add(DownloadLatestEmojiDataJob(true))
}
)
dividerPref()
sectionHeaderPref(R.string.preferences__internal_sender_key)

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
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()
object RequestTokenError : DonationEvent()
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
class SubscriptionCancellationFailed(val throwable: Throwable) : DonationEvent()
object SubscriptionCancelled : DonationEvent()
}

View File

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

View File

@@ -0,0 +1,224 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import android.app.Activity
import android.content.Intent
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.Environment
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Manages bindings with payment APIs
*
* Steps for setting up payments for a subscription:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
* 1. Create a SetupIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the SetupIntent via the Stripe API
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
*
* For Boosts:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Create a PaymentIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the PaymentIntent via the Stripe API
*/
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
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 requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
googlePayApi.requestPayment(price, label, requestCode)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?,
expectedRequestCode: Int,
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
) {
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
}
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
return stripeApi.createPaymentIntent(price)
.flatMapCompletable { result ->
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(Exception("Amount is too small"))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(Exception("Amount is too large"))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(Exception("Currency is not supported"))
is StripeApi.CreatePaymentIntentResult.Success -> stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), result.paymentIntent)
}
}
}
fun continueSubscriptionSetup(paymentData: PaymentData): Completable {
return stripeApi.createSetupIntent()
.flatMapCompletable { result ->
stripeApi.confirmSetupIntent(GooglePayPaymentSource(paymentData), result.setupIntent)
}
}
fun cancelActiveSubscription(): Completable {
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
return ApplicationDependencies.getDonationsService().cancelSubscription(localSubscriber.subscriberId).flatMapCompletable {
when {
it.status == 200 -> Completable.complete()
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
it.executionError.isPresent -> Completable.error(it.executionError.get())
else -> Completable.error(AssertionError("Something bad happened"))
}
}
}
fun ensureSubscriberId(): Completable {
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
return ApplicationDependencies
.getDonationsService()
.putSubscription(subscriberId)
.flatMapCompletable {
when {
it.status == 200 -> Completable.complete()
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
it.executionError.isPresent -> Completable.error(it.executionError.get())
else -> Completable.error(AssertionError("Something bad happened"))
}
}
.doOnComplete {
SignalStore
.donationsValues()
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
}
}
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
subscriber.subscriberId,
subscriptionLevel,
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize()
).flatMapCompletable { response ->
when {
response.status == 200 -> Completable.complete()
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
response.executionError.isPresent -> Completable.error(response.executionError.get())
else -> Completable.error(AssertionError("should never happen"))
}
}.andThen {
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
it.onComplete()
}.andThen {
val jobIds = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
val countDownLatch = CountDownLatch(2)
val firstJobListener = JobTracker.JobListener { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
val secondJobListener = JobTracker.JobListener { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
ApplicationDependencies.getJobManager().addListener(jobIds.first(), firstJobListener)
ApplicationDependencies.getJobManager().addListener(jobIds.second(), secondJobListener)
try {
if (!countDownLatch.await(10, TimeUnit.SECONDS)) {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
} else {
it.onComplete()
}
} catch (e: InterruptedException) {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
}
}.subscribeOn(Schedulers.io())
}
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation()
if (levelUpdateOperation == null || subscriptionLevel != levelUpdateOperation.level) {
val newOperation = LevelUpdateOperation(
idempotencyKey = IdempotencyKey.generate(),
level = subscriptionLevel
)
SignalStore.donationsValues().setLevelOperation(newOperation)
newOperation
} else {
levelUpdateOperation
}
}
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
return ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
.flatMap { response ->
when {
response.status == 200 -> Single.just(StripeApi.PaymentIntent(response.result.get().id, response.result.get().clientSecret))
response.executionError.isPresent -> Single.error(response.executionError.get())
response.applicationError.isPresent -> Single.error(response.applicationError.get())
else -> Single.error(AssertionError("should never get here"))
}
}
}
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
return Single.fromCallable {
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId)
}.flatMap { response ->
when {
response.status == 200 -> Single.just(StripeApi.SetupIntent(response.result.get().id, response.result.get().clientSecret))
response.executionError.isPresent -> Single.error(response.executionError.get())
response.applicationError.isPresent -> Single.error(response.applicationError.get())
else -> Single.error(AssertionError("should never get here"))
}
}
}
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
return Single.fromCallable {
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
ApplicationDependencies.getDonationsService().setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
}.flatMapCompletable { response ->
when {
response.status == 200 -> Completable.complete()
response.executionError.isPresent -> Completable.error(response.executionError.get())
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
else -> Completable.error(AssertionError("Should never get here"))
}
}
}
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
/**
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
* in the currency indicated.
*/
class SubscriptionsRepository(private val donationsService: DonationsService) {
fun getActiveSubscription(): Single<ActiveSubscription> {
val localSubscription = SignalStore.donationsValues().getSubscriber()
return if (localSubscription != null) {
donationsService.getSubscription(localSubscription.subscriberId).flatMap {
when {
it.status == 200 -> Single.just(it.result.get())
it.applicationError.isPresent -> Single.error(it.applicationError.get())
it.executionError.isPresent -> Single.error(it.executionError.get())
else -> throw AssertionError()
}
}
} else {
Single.just(ActiveSubscription(null))
}
}
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = donationsService.subscriptionLevels.map { response ->
response.result.transform { subscriptionLevels ->
subscriptionLevels.levels.map { (code, level) ->
Subscription(
id = code,
title = level.badge.name,
badge = Badges.fromServiceBadge(level.badge),
price = FiatMoney(level.currencies[currency.currencyCode]!!, currency),
level = code.toInt()
)
}.sortedBy {
it.level
}
}.or(emptyList())
}
}

View File

@@ -0,0 +1,187 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
import android.text.method.DigitsKeyListener
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.widget.addTextChangedListener
import com.google.android.material.button.MaterialButton
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
import java.lang.Integer.min
import java.util.Currency
import java.util.Locale
import java.util.regex.Pattern
/**
* A Signal Boost is a one-time ephemeral show of support. Each boost level
* can unlock a corresponding badge for a time determined by the server.
*/
data class Boost(
val badge: Badge,
val price: FiatMoney
) {
/**
* A heading containing a 96dp rendering of the boost's badge.
*/
class HeadingModel(
val boostBadge: Badge
) : PreferenceModel<HeadingModel>() {
override fun areItemsTheSame(newItem: HeadingModel): Boolean = true
override fun areContentsTheSame(newItem: HeadingModel): Boolean {
return super.areContentsTheSame(newItem) && newItem.boostBadge == boostBadge
}
}
/**
* A widget that allows a user to select from six different amounts, or enter a custom amount.
*/
class SelectionModel(
val boosts: List<Boost>,
val selectedBoost: Boost?,
val currency: Currency,
override val isEnabled: Boolean,
val onBoostClick: (Boost) -> Unit,
val isCustomAmountFocused: Boolean,
val onCustomAmountChanged: (String) -> Unit,
val onCustomAmountFocusChanged: (Boolean) -> Unit,
) : PreferenceModel<SelectionModel>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: SelectionModel): Boolean = true
override fun areContentsTheSame(newItem: SelectionModel): Boolean {
return super.areContentsTheSame(newItem) &&
newItem.boosts == boosts &&
newItem.selectedBoost == selectedBoost &&
newItem.currency == currency &&
newItem.isCustomAmountFocused == isCustomAmountFocused
}
}
private class SelectionViewHolder(itemView: View) : MappingViewHolder<SelectionModel>(itemView) {
private val boost1: MaterialButton = itemView.findViewById(R.id.boost_1)
private val boost2: MaterialButton = itemView.findViewById(R.id.boost_2)
private val boost3: MaterialButton = itemView.findViewById(R.id.boost_3)
private val boost4: MaterialButton = itemView.findViewById(R.id.boost_4)
private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5)
private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6)
private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom)
private var filter: MoneyFilter? = null
init {
custom.filters = emptyArray()
}
override fun bind(model: SelectionModel) {
itemView.isEnabled = model.isEnabled
model.boosts.zip(listOf(boost1, boost2, boost3, boost4, boost5, boost6)).forEach { (boost, button) ->
button.isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused
button.text = FiatMoneyUtil.format(
context.resources,
boost.price,
FiatMoneyUtil.formatOptions()
)
button.setOnClickListener {
model.onBoostClick(boost)
custom.clearFocus()
}
}
if (filter == null || filter?.currency != model.currency) {
custom.removeTextChangedListener(filter)
filter = MoneyFilter(model.currency) {
model.onCustomAmountChanged(it)
}
custom.keyListener = filter
custom.addTextChangedListener(filter)
custom.setText("")
}
custom.setOnFocusChangeListener { _, hasFocus ->
model.onCustomAmountFocusChanged(hasFocus)
}
if (model.isCustomAmountFocused && !custom.hasFocus()) {
ViewUtil.focusAndShowKeyboard(custom)
}
}
}
private class HeadingViewHolder(itemView: View) : MappingViewHolder<HeadingModel>(itemView) {
private val badgeImageView: BadgeImageView = itemView as BadgeImageView
override fun bind(model: HeadingModel) {
badgeImageView.setBadge(model.boostBadge)
}
}
@VisibleForTesting
class MoneyFilter(val currency: Currency, private val onCustomAmountChanged: (String) -> Unit = {}) : DigitsKeyListener(), TextWatcher {
val separatorCount = min(1, currency.defaultFractionDigits)
val prefix: String = "${currency.getSymbol(Locale.getDefault())} "
val pattern: Pattern = "[0-9]*([.,]){0,$separatorCount}[0-9]{0,${currency.defaultFractionDigits}}".toPattern()
override fun filter(
source: CharSequence,
start: Int,
end: Int,
dest: Spanned,
dstart: Int,
dend: Int
): CharSequence? {
val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length)
val resultWithoutCurrencyPrefix = result.removePrefix(prefix)
val matcher = pattern.matcher(resultWithoutCurrencyPrefix)
if (!matcher.matches()) {
return dest.subSequence(dstart, dend)
}
return null
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
if (s.isNullOrEmpty()) return
val hasPrefix = s.startsWith(prefix)
if (hasPrefix && s.length == prefix.length) {
s.clear()
} else if (!hasPrefix) {
s.insert(0, prefix)
}
onCustomAmountChanged(s.removePrefix(prefix).toString())
}
}
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference))
adapter.registerFactory(HeadingModel::class.java, MappingAdapter.LayoutFactory({ HeadingViewHolder(it) }, R.layout.boost_preview_preference))
}
}
}

View File

@@ -0,0 +1,202 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.text.SpannableStringBuilder
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgePreview
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.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.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
/**
* UX to allow users to donate ephemerally.
*/
class BoostFragment : DSLSettingsBottomSheetFragment(
layoutId = R.layout.boost_bottom_sheet
) {
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var processingDonationPaymentDialog: AlertDialog
private val sayThanks: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
.append(" ")
.append(
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
// TODO [alex] -- Where's this go?
}
)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
CurrencySelection.register(adapter)
BadgePreview.register(adapter)
Boost.register(adapter)
GooglePayButton.register(adapter)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
.setCancelable(false)
.create()
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
when (event) {
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(event.throwable)
is DonationEvent.PaymentConfirmationError -> onPaymentError(event.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
DonationEvent.RequestTokenError -> onPaymentError(null)
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
is DonationEvent.SubscriptionCancellationFailed -> Unit
}
}
}
private fun getConfiguration(state: BoostState): DSLConfiguration {
if (state.stage == BoostState.Stage.PAYMENT_PIPELINE) {
processingDonationPaymentDialog.show()
} else {
processingDonationPaymentDialog.hide()
}
return configure {
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
sectionHeaderPref(
title = DSLSettingsText.from(
R.string.BoostFragment__give_signal_a_boost,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
noPadTextPref(
title = DSLSettingsText.from(
sayThanks,
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(28f).toInt())
customPref(
CurrencySelection.Model(
currencySelection = state.currencySelection,
isEnabled = state.stage == BoostState.Stage.READY,
onClick = {
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment(true))
}
)
)
customPref(
Boost.SelectionModel(
boosts = state.boosts,
selectedBoost = state.selectedBoost,
currency = state.customAmount.currency,
isCustomAmountFocused = state.isCustomAmountFocused,
isEnabled = state.stage == BoostState.Stage.READY,
onBoostClick = {
viewModel.setSelectedBoost(it)
},
onCustomAmountChanged = {
viewModel.setCustomAmount(it)
},
onCustomAmountFocusChanged = {
viewModel.setCustomAmountFocused(it)
}
)
)
if (state.isGooglePayAvailable) {
space(DimensionUnit.DP.toPixels(16f).toInt())
customPref(
GooglePayButton.Model(
onClick = this@BoostFragment::onGooglePayButtonClicked,
isEnabled = state.stage == BoostState.Stage.READY
)
)
}
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
onClick = {
// TODO
}
)
}
}
private fun onGooglePayButtonClicked() {
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__signal_boost))
}
private fun onPaymentConfirmed(boostBadge: Badge) {
findNavController().navigate(
BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true),
NavOptions.Builder().setPopUpTo(R.id.boostFragment, true).build()
)
}
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Error occurred while redeeming token", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
} else {
Log.w(TAG, "Error occurred while processing payment", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__payment_failed)
.setMessage(R.string.DonationsErrors__your_payment)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
}
private fun onGooglePayUnavailable(throwable: Throwable?) {
Log.w(TAG, "Google Pay error", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
companion object {
private val TAG = Log.tag(BoostFragment::class.java)
}
}

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.net.Uri
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import java.math.BigDecimal
import java.util.Currency
class BoostRepository {
fun getBoosts(currency: Currency): Single<Pair<List<Boost>, Boost?>> {
val boosts = testBoosts(currency)
return Single.just(
Pair(
boosts,
boosts[2]
)
)
}
fun getBoostBadge(): Single<Badge> = Single.fromCallable {
// Get boost badge from server
// throw NotImplementedError()
testBadge
}
companion object {
private val testBadge = Badge(
id = "TEST",
category = Badge.Category.Testing,
name = "Test Badge",
description = "Test Badge",
imageUrl = Uri.EMPTY,
imageDensity = "xxxhdpi",
expirationTimestamp = 0L,
visible = false,
)
private fun testBoosts(currency: Currency) = listOf(
3L, 5L, 10L, 20L, 50L, 100L
).map {
Boost(testBadge, FiatMoney(BigDecimal.valueOf(it), currency))
}
}
}

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import java.math.BigDecimal
import java.util.Currency
data class BoostState(
val boostBadge: Badge? = null,
val currencySelection: CurrencySelection = CurrencySelection("USD"),
val isGooglePayAvailable: Boolean = false,
val boosts: List<Boost> = listOf(),
val selectedBoost: Boost? = null,
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
val isCustomAmountFocused: Boolean = false,
val stage: Stage = Stage.INIT,
) {
enum class Stage {
INIT,
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
}
}

View File

@@ -0,0 +1,175 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.content.Intent
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.money.FiatMoney
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.models.CurrencySelection
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.livedata.Store
import java.math.BigDecimal
class BoostViewModel(
private val boostRepository: BoostRepository,
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModel() {
private val store = Store(BoostState())
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
val state: LiveData<BoostState> = store.stateLiveData
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
private var boostToPurchase: Boost? = null
override fun onCleared() {
disposables.clear()
}
init {
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
val boostBadge = boostRepository.getBoostBadge()
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) { (boosts, defaultBoost), badge -> BoostInfo(boosts, defaultBoost, badge) }.subscribe { info ->
store.update {
it.copy(
boosts = info.boosts,
selectedBoost = if (it.selectedBoost in info.boosts) it.selectedBoost else info.defaultBoost,
boostBadge = it.boostBadge ?: info.boostBadge,
stage = if (it.stage == BoostState.Stage.INIT) BoostState.Stage.READY else it.stage
)
}
}
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(
currencySelection = CurrencySelection(currency.currencyCode),
isCustomAmountFocused = false,
customAmount = FiatMoney(
BigDecimal.ZERO, currency
)
)
}
}
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
donationPaymentRepository.onActivityResult(
requestCode,
resultCode,
data,
this.fetchTokenRequestCode,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
val boost = boostToPurchase
boostToPurchase = null
if (boost != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
onError = { throwable ->
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
},
onComplete = {
// TODO [alex] Now we need to do the whole query for a token, submit token rigamarole
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
}
)
} else {
store.update { it.copy(stage = BoostState.Stage.READY) }
}
}
override fun onError() {
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = BoostState.Stage.READY) }
}
}
)
}
fun requestTokenFromGooglePay(label: String) {
val snapshot = store.state
if (snapshot.selectedBoost == null) {
return
}
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
// TODO [alex] -- Do we want prevalidation? Stripe will catch us anyway.
// TODO [alex] -- Custom boost badge details... how do we determine this?
boostToPurchase = if (snapshot.isCustomAmountFocused) {
Boost(snapshot.selectedBoost.badge, snapshot.customAmount)
} else {
snapshot.selectedBoost
}
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedBoost.price, label, fetchTokenRequestCode)
}
fun setSelectedBoost(boost: Boost) {
store.update {
it.copy(
isCustomAmountFocused = false,
selectedBoost = boost
)
}
}
fun setCustomAmount(amount: String) {
val bigDecimalAmount = if (amount.isEmpty()) {
BigDecimal.ZERO
} else {
BigDecimal(amount)
}
store.update { it.copy(customAmount = FiatMoney(bigDecimalAmount, it.customAmount.currency)) }
}
fun setCustomAmountFocused(isFocused: Boolean) {
store.update { it.copy(isCustomAmountFocused = isFocused) }
}
private data class BoostInfo(val boosts: List<Boost>, val defaultBoost: Boost?, val boostBadge: Badge)
class Factory(
private val boostRepository: BoostRepository,
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import androidx.fragment.app.viewModels
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 java.util.Locale
/**
* Simple fragment for selecting a currency for Donations
*/
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: SetCurrencyViewModel by viewModels(
factoryProducer = {
SetCurrencyViewModel.Factory(SetCurrencyFragmentArgs.fromBundle(requireArguments()).isBoost)
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: SetCurrencyState): DSLConfiguration {
return configure {
state.currencies.forEach { currency ->
radioPref(
title = DSLSettingsText.from(currency.getDisplayName(Locale.getDefault())),
summary = DSLSettingsText.from(currency.currencyCode),
isChecked = currency.currencyCode == state.selectedCurrencyCode,
onClick = {
viewModel.setSelectedCurrency(currency.currencyCode)
}
)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import java.util.Currency
data class SetCurrencyState(
val selectedCurrencyCode: String = "",
val currencies: List<Currency> = listOf()
)

View File

@@ -0,0 +1,80 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Currency
import java.util.Locale
class SetCurrencyViewModel(val isBoost: Boolean) : ViewModel() {
private val store = Store(SetCurrencyState())
val state: LiveData<SetCurrencyState> = store.stateLiveData
init {
val defaultCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
store.update { state ->
val platformCurrencies = Currency.getAvailableCurrencies()
val stripeCurrencies = platformCurrencies
.filter { StripeApi.Validation.supportedCurrencyCodes.contains(it.currencyCode) }
.sortedWith(CurrencyComparator(BuildConfig.DEFAULT_CURRENCIES.split(",")))
state.copy(
selectedCurrencyCode = defaultCurrency.currencyCode,
currencies = stripeCurrencies
)
}
}
fun setSelectedCurrency(selectedCurrencyCode: String) {
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
if (isBoost) {
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
} else {
SignalStore.donationsValues().setSubscriptionCurrency(Currency.getInstance(selectedCurrencyCode))
}
}
@VisibleForTesting
class CurrencyComparator(private val defaults: List<String>) : Comparator<Currency> {
companion object {
private const val USD = "USD"
}
override fun compare(o1: Currency, o2: Currency): Int {
val isO1Default = o1.currencyCode in defaults
val isO2Default = o2.currencyCode in defaults
return if (o1.currencyCode == o2.currencyCode) {
0
} else if (o1.currencyCode == USD) {
-1
} else if (o2.currencyCode == USD) {
1
} else if (isO1Default && isO2Default) {
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
} else if (isO1Default) {
-1
} else if (isO2Default) {
1
} else {
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
}
}
}
class Factory(private val isBoost: Boolean) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(SetCurrencyViewModel(isBoost))!!
}
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import java.util.Locale
/**
* DSL renderable item that displays active subscription information on the user's
* manage donations page.
*/
object ActiveSubscriptionPreference {
class Model(
val subscription: Subscription,
val onAddBoostClick: () -> Unit,
val renewalTimestamp: Long = -1L
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return subscription.id == newItem.subscription.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
subscription == newItem.subscription &&
renewalTimestamp == newItem.renewalTimestamp
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
val badge: BadgeImageView = itemView.findViewById(R.id.my_support_badge)
val title: TextView = itemView.findViewById(R.id.my_support_title)
val price: TextView = itemView.findViewById(R.id.my_support_price)
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
override fun bind(model: Model) {
badge.setBadge(model.subscription.badge)
title.text = model.subscription.title
price.text = context.getString(
R.string.MySupportPreference__s_per_month,
FiatMoneyUtil.format(
context.resources,
model.subscription.price,
FiatMoneyUtil.formatOptions()
)
)
expiry.text = context.getString(
R.string.MySupportPreference__renews_s,
DateUtils.formatDate(
Locale.getDefault(),
model.renewalTimestamp
)
)
boost.setOnClickListener {
model.onAddBoostClick()
}
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.my_support_preference))
}
}

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
enum class ManageDonationsEvent {
NOT_SUBSCRIBED,
ERROR_GETTING_SUBSCRIPTION
}

View File

@@ -0,0 +1,151 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import java.util.concurrent.TimeUnit
/**
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
* a subscriber. Used to manage their current subscription, view badges, and boost.
*/
class ManageDonationsFragment : DSLSettingsFragment() {
private val viewModel: ManageDonationsViewModel by viewModels(
factoryProducer = {
ManageDonationsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
private val lifecycleDisposable = LifecycleDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
if (args.skipToSubscribe) {
findNavController().navigate(
ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment(),
NavOptions.Builder().setPopUpTo(R.id.manageDonationsFragment, true).build()
)
}
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
if (args.skipToSubscribe) {
return
}
ActiveSubscriptionPreference.register(adapter)
IndeterminateLoadingCircle.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
when (event) {
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
}
}
}
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
return configure {
sectionHeaderPref(
title = DSLSettingsText.from(
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
noPadTextPref(
title = DSLSettingsText.from(
R.string.ManageDonationsFragment__my_support,
DSLSettingsText.Title2BoldModifier
)
)
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
val activeSubscription = state.transactionState.activeSubscription
if (activeSubscription.isActive) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.activeSubscription.level == it.level }
if (subscription != null) {
space(DimensionUnit.DP.toPixels(16f).toInt())
customPref(
ActiveSubscriptionPreference.Model(
subscription = subscription,
onAddBoostClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
},
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.activeSubscription.endOfCurrentPeriod)
)
)
dividerPref()
} else {
customPref(IndeterminateLoadingCircle)
}
} else {
customPref(IndeterminateLoadingCircle)
}
} else {
customPref(IndeterminateLoadingCircle)
}
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
onClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
)
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
onClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscriptionBadgeManageFragment())
}
)
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
linkId = R.string.donate_url
)
}
}
private fun handleUserIsNotSubscribed() {
findNavController().popBackStack()
}
private fun handleErrorGettingSubscription() {
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
data class ManageDonationsState(
val featuredBadge: Badge? = null,
val transactionState: TransactionState = TransactionState.Init,
val availableSubscriptions: List<Subscription> = emptyList()
) {
sealed class TransactionState {
object Init : TransactionState()
object InTransaction : TransactionState()
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
}
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
class ManageDonationsViewModel(
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModel() {
private val store = Store(ManageDonationsState())
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
private val disposables = CompositeDisposable()
val state: LiveData<ManageDonationsState> = store.stateLiveData
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
state.copy(featuredBadge = self.featuredBadge)
}
}
override fun onCleared() {
disposables.clear()
}
fun refresh() {
disposables.clear()
val levelUpdateOperationEdges: Observable<Optional<LevelUpdateOperation>> = SignalStore.donationsValues().levelUpdateOperationObservable.distinctUntilChanged()
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription()
disposables += levelUpdateOperationEdges.flatMapSingle { optionalKey ->
if (optionalKey.isPresent) {
Single.just(ManageDonationsState.TransactionState.InTransaction)
} else {
activeSubscription.map { ManageDonationsState.TransactionState.NotInTransaction(it) }
}
}.subscribeBy(
onNext = { transactionState ->
store.update {
it.copy(transactionState = transactionState)
}
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && !transactionState.activeSubscription.isActive) {
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
}
},
onError = {
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
}
)
disposables += subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency()).subscribeBy { subs ->
store.update { it.copy(availableSubscriptions = subs) }
}
}
class Factory(
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
}
}
}

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
data class CurrencySelection(
val selectedCurrencyCode: String,
) {
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_currency_selection))
}
}
class Model(
val currencySelection: CurrencySelection,
override val isEnabled: Boolean,
val onClick: () -> Unit
) : PreferenceModel<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
newItem.currencySelection.selectedCurrencyCode == currencySelection.selectedCurrencyCode
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val spinner: TextView = itemView.findViewById(R.id.subscription_currency_selection_spinner)
override fun bind(model: Model) {
spinner.text = model.currencySelection.selectedCurrencyCode
if (model.isEnabled) {
itemView.setOnClickListener { model.onClick() }
}
}
}
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object GooglePayButton {
class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val googlePayButton: View = findViewById(R.id.googlepay_button)
override fun bind(model: Model) {
googlePayButton.isEnabled = model.isEnabled
googlePayButton.setOnClickListener {
googlePayButton.isEnabled = false
model.onClick()
}
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref))
}
}

View File

@@ -0,0 +1,280 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.graphics.Color
import android.text.SpannableStringBuilder
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
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 org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
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.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* UX for creating and changing a subscription
*/
class SubscribeFragment : DSLSettingsFragment(
layoutId = R.layout.subscribe_fragment
) {
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
private val lifecycleDisposable = LifecycleDisposable()
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__support_technology_that_is_built_for_you))
.append(" ")
.append(
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeLearnMoreBottomSheetDialog())
}
)
}
private lateinit var processingDonationPaymentDialog: AlertDialog
override fun onResume() {
super.onResume()
viewModel.refreshActiveSubscription()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgePreview.register(adapter)
CurrencySelection.register(adapter)
Subscription.register(adapter)
GooglePayButton.register(adapter)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
.setCancelable(false)
.create()
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe {
when (it) {
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(it.throwable)
is DonationEvent.PaymentConfirmationError -> onPaymentError(it.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
DonationEvent.RequestTokenError -> onPaymentError(null)
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable)
}
}
}
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
if (state.hasInProgressSubscriptionTransaction || state.stage == SubscribeState.Stage.PAYMENT_PIPELINE) {
processingDonationPaymentDialog.show()
} else {
processingDonationPaymentDialog.hide()
}
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
return configure {
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
sectionHeaderPref(
title = DSLSettingsText.from(
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
noPadTextPref(
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
customPref(
CurrencySelection.Model(
currencySelection = state.currencySelection,
isEnabled = areFieldsEnabled && state.activeSubscription?.isActive != true,
onClick = {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment(false))
}
)
)
state.subscriptions.forEach {
val isActive = state.activeSubscription?.activeSubscription?.level == it.level
customPref(
Subscription.Model(
subscription = it,
isSelected = state.selectedSubscription == it,
isEnabled = areFieldsEnabled,
isActive = isActive,
willRenew = isActive && state.activeSubscription?.activeSubscription?.willCancelAtPeriodEnd() ?: false,
onClick = { viewModel.setSelectedSubscription(it) },
renewalTimestamp = TimeUnit.SECONDS.toMillis(state.activeSubscription?.activeSubscription?.endOfCurrentPeriod ?: 0L)
)
)
}
if (state.activeSubscription?.isActive == true) {
space(DimensionUnit.DP.toPixels(16f).toInt())
val activeAndSameLevel = state.activeSubscription.isActive &&
state.selectedSubscription?.level == state.activeSubscription.activeSubscription?.level
val isExpiring = state.activeSubscription.isActive && state.activeSubscription.activeSubscription?.willCancelAtPeriodEnd() == true
primaryButton(
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
isEnabled = areFieldsEnabled && (!activeAndSameLevel || isExpiring),
onClick = {
val calendar = Calendar.getInstance()
calendar.add(Calendar.MONTH, 1)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__update_subscription_question)
.setMessage(
getString(
R.string.SubscribeFragment__you_will_be_charged_the_full_amount,
DateUtils.formatDateWithYear(Locale.getDefault(), calendar.timeInMillis)
)
)
.setPositiveButton(R.string.SubscribeFragment__update) { dialog, _ ->
dialog.dismiss()
viewModel.updateSubscription()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
isEnabled = areFieldsEnabled,
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
.setPositiveButton(R.string.SubscribeFragment__confirm) { d, _ ->
d.dismiss()
viewModel.cancel()
}
.setNegativeButton(R.string.SubscribeFragment__not_now) { d, _ ->
d.dismiss()
}
.show()
}
)
} else {
if (state.isGooglePayAvailable) {
space(DimensionUnit.DP.toPixels(16f).toInt())
customPref(
GooglePayButton.Model(
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
isEnabled = areFieldsEnabled && state.selectedSubscription != null
)
)
}
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
onClick = {
// TODO [alex] support page
}
)
}
}
}
private fun onGooglePayButtonClicked() {
viewModel.requestTokenFromGooglePay()
}
private fun onPaymentConfirmed(badge: Badge) {
findNavController().navigate(
SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false),
)
}
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Error occurred while redeeming token", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
} else {
Log.w(TAG, "Error occurred while processing payment", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__payment_failed)
.setMessage(R.string.DonationsErrors__your_payment)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
}
}
}
private fun onGooglePayUnavailable(throwable: Throwable?) {
Log.w(TAG, "Google Pay error", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
private fun onSubscriptionCancelled() {
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG)
.setTextColor(Color.WHITE)
.show()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.home(requireContext()))
}
private fun onSubscriptionFailedToCancel(throwable: Throwable) {
Log.w(TAG, "Failed to cancel subscription", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
companion object {
private val TAG = Log.tag(SubscribeFragment::class.java)
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
class SubscribeLearnMoreBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.subscribe_learn_more_bottom_sheet_dialog_fragment, container, false)
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
data class SubscribeState(
val currencySelection: CurrencySelection = CurrencySelection("USD"),
val subscriptions: List<Subscription> = listOf(),
val selectedSubscription: Subscription? = null,
val activeSubscription: ActiveSubscription? = null,
val isGooglePayAvailable: Boolean = false,
val stage: Stage = Stage.INIT,
val hasInProgressSubscriptionTransaction: Boolean = false,
) {
enum class Stage {
INIT,
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
CANCELLING
}
}

View File

@@ -0,0 +1,198 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.content.Intent
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.donations.GooglePayApi
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.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
class SubscribeViewModel(
private val subscriptionsRepository: SubscriptionsRepository,
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModel() {
private val store = Store(SubscribeState())
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
val state: LiveData<SubscribeState> = store.stateLiveData
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
private var subscriptionToPurchase: Subscription? = null
private val activeSubscriptionSubject = PublishSubject.create<ActiveSubscription>()
override fun onCleared() {
disposables.clear()
}
init {
val currency: Observable<Currency> = SignalStore.donationsValues().observableSubscriptionCurrency
val allSubscriptions: Observable<List<Subscription>> = currency.switchMapSingle { subscriptionsRepository.getSubscriptions(it) }
refreshActiveSubscription()
disposables += SignalStore.donationsValues().levelUpdateOperationObservable.subscribeBy {
store.update { state ->
state.copy(
hasInProgressSubscriptionTransaction = it.isPresent
)
}
}
disposables += Observable.combineLatest(allSubscriptions, activeSubscriptionSubject, ::Pair).subscribe { (subs, active) ->
store.update {
it.copy(
subscriptions = subs,
selectedSubscription = it.selectedSubscription ?: resolveSelectedSubscription(active, subs),
activeSubscription = active,
stage = if (it.stage == SubscribeState.Stage.INIT) SubscribeState.Stage.READY else it.stage,
)
}
}
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
)
disposables += currency.map { CurrencySelection(it.currencyCode) }.subscribe { selection ->
store.update { it.copy(currencySelection = selection) }
}
}
fun refreshActiveSubscription() {
subscriptionsRepository
.getActiveSubscription()
.subscribeBy { activeSubscriptionSubject.onNext(it) }
}
private fun resolveSelectedSubscription(activeSubscription: ActiveSubscription, subscriptions: List<Subscription>): Subscription? {
return if (activeSubscription.isActive) {
subscriptions.firstOrNull { it.level == activeSubscription.activeSubscription.level }
} else {
subscriptions.firstOrNull()
}
}
fun cancel() {
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
onComplete = {
eventPublisher.onNext(DonationEvent.SubscriptionCancelled)
SignalStore.donationsValues().setLastEndOfPeriod(0L)
refreshActiveSubscription()
store.update { it.copy(stage = SubscribeState.Stage.READY) }
},
onError = { throwable ->
eventPublisher.onNext(DonationEvent.SubscriptionCancellationFailed(throwable))
store.update { it.copy(stage = SubscribeState.Stage.READY) }
}
)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
val subscription = subscriptionToPurchase
subscriptionToPurchase = null
donationPaymentRepository.onActivityResult(
requestCode, resultCode, data, this.fetchTokenRequestCode,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
if (subscription != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId()
val continueSetup = donationPaymentRepository.continueSubscriptionSetup(paymentData)
val setLevel = donationPaymentRepository.setSubscriptionLevel(subscription.level.toString())
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
ensureSubscriberId.andThen(continueSetup).andThen(setLevel).subscribeBy(
onError = { throwable ->
refreshActiveSubscription()
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
},
onComplete = {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
}
)
} else {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
}
}
override fun onError() {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
}
}
)
}
fun updateSubscription() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.setSubscriptionLevel(store.state.selectedSubscription!!.level.toString())
.subscribeBy(
onComplete = {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.selectedSubscription!!.badge))
},
onError = { throwable ->
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
}
)
}
fun requestTokenFromGooglePay() {
val snapshot = store.state
if (snapshot.selectedSubscription == null) {
return
}
store.update { it.copy(stage = SubscribeState.Stage.TOKEN_REQUEST) }
subscriptionToPurchase = snapshot.selectedSubscription
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, fetchTokenRequestCode)
}
fun setSelectedSubscription(subscription: Subscription) {
store.update { it.copy(selectedSubscription = subscription) }
}
class Factory(
private val subscriptionsRepository: SubscriptionsRepository,
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}
}

View File

@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
import android.animation.Animator
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.navigation.fragment.findNavController
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.visible
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
private lateinit var switch: SwitchMaterial
private lateinit var heading: TextView
private lateinit var badgeRepository: BadgeRepository
private lateinit var controlState: ControlState
private enum class ControlState {
FEATURE,
DISPLAY
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
badgeRepository = BadgeRepository(requireContext())
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
val lottie: LottieAnimationView = view.findViewById(R.id.thanks_bottom_sheet_lottie)
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
val controlText: TextView = view.findViewById(R.id.thanks_bottom_sheet_control_text)
val controlNote: View = view.findViewById(R.id.thanks_bottom_sheet_featured_note)
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
switch = view.findViewById(R.id.thanks_bottom_sheet_switch)
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
badgeView.setBadge(args.badge)
badgeName.text = args.badge.name
val otherBadges = Recipient.self().badges.filterNot { it.id == args.badge.id }
val hasOtherBadges = otherBadges.isNotEmpty()
val displayingBadges = otherBadges.all { it.visible }
if (hasOtherBadges && displayingBadges) {
switch.isChecked = false
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge)
controlNote.visible = true
controlState = ControlState.FEATURE
} else if (hasOtherBadges && !displayingBadges) {
switch.isChecked = false
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
controlNote.visible = false
controlState = ControlState.DISPLAY
} else {
switch.isChecked = true
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
controlNote.visible = false
controlState = ControlState.DISPLAY
}
if (args.isBoost) {
presentBoostCopy()
lottie.visible = true
lottie.playAnimation()
lottie.addAnimatorListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator?) {
lottie.removeAnimatorListener(this)
lottie.setMinAndMaxFrame(30, 91)
lottie.repeatMode = LottieDrawable.RESTART
lottie.repeatCount = LottieDrawable.INFINITE
lottie.frame = 30
lottie.playAnimation()
}
})
} else {
presentSubscriptionCopy()
lottie.visible = false
}
done.setOnClickListener { dismissAllowingStateLoss() }
}
override fun onDismiss(dialog: DialogInterface) {
val controlChecked = switch.isChecked
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
if (controlState == ControlState.DISPLAY) {
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
} else {
badgeRepository.setFeaturedBadge(args.badge).subscribe()
}
if (args.isBoost) {
findNavController().popBackStack()
} else {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
}
private fun presentBoostCopy() {
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost)
}
private fun presentSubscriptionCopy() {
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support)
}
}

View File

@@ -17,9 +17,9 @@ 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
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.thoughtcrime.securesms.AvatarPreviewActivity
@@ -30,6 +30,10 @@ import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.VerifyIdentityActivity
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.Badges.displayBadges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
@@ -85,7 +89,8 @@ private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
class ConversationSettingsFragment : DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
menuId = R.menu.conversation_settings,
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
) {
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
@@ -175,6 +180,8 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
BioTextPreference.register(adapter)
AvatarPreference.register(adapter)
ButtonStripPreference.register(adapter)
@@ -185,6 +192,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
GroupDescriptionPreference.register(adapter)
LegacyGroupPreference.register(adapter)
val recipientId = args.recipientId
if (recipientId != null) {
Badge.register(adapter) { badge, _, _ ->
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, recipientId, badge)
}
}
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.recipient != Recipient.UNKNOWN) {
@@ -245,6 +259,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
}
}
},
onBadgeClick = { badge ->
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, state.recipient.id, badge)
}
)
)
@@ -297,18 +314,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
}
state.withRecipientSettingsState { recipientState ->
if (recipientState.displayInternalRecipientDetails) {
customPref(
InternalPreference.Model(
recipient = state.recipient,
onInternalDetailsClicked = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToInternalDetailsSettingsFragment(state.recipient.id)
navController.navigate(action)
}
)
if (state.displayInternalRecipientDetails) {
customPref(
InternalPreference.Model(
recipient = state.recipient,
onInternalDetailsClicked = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToInternalDetailsSettingsFragment(state.recipient.id)
navController.navigate(action)
}
)
}
)
}
customPref(
@@ -466,12 +481,20 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
}
state.withRecipientSettingsState { groupState ->
if (groupState.selfHasGroups) {
state.withRecipientSettingsState { recipientSettingsState ->
if (state.recipient.badges.isNotEmpty()) {
dividerPref()
sectionHeaderPref(R.string.ManageProfileFragment_badges)
displayBadges(requireContext(), state.recipient.badges)
}
if (recipientSettingsState.selfHasGroups) {
dividerPref()
val groupsInCommonCount = groupState.allGroupsInCommon.size
val groupsInCommonCount = recipientSettingsState.allGroupsInCommon.size
sectionHeaderPref(
DSLSettingsText.from(
if (groupsInCommonCount == 0) {
@@ -496,7 +519,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
)
for (group in groupState.groupsInCommon) {
for (group in recipientSettingsState.groupsInCommon) {
customPref(
RecipientPreference.Model(
recipient = group,
@@ -508,7 +531,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
}
if (groupState.canShowMoreGroupsInCommon) {
if (recipientSettingsState.canShowMoreGroupsInCommon) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
@@ -637,7 +660,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
clickPref(
title = DSLSettingsText.from(title, titleTint),
title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title),
icon = DSLSettingsIcon.from(blockUnblockIcon),
onClick = {
if (state.recipient.isBlocked) {
@@ -718,7 +741,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private val rect = Rect()
override fun getAnimationState(recyclerView: RecyclerView): AnimationState {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val layoutManager = recyclerView.layoutManager as FlexboxLayoutManager
return if (layoutManager.findFirstVisibleItemPosition() == 0) {
val firstChild = requireNotNull(layoutManager.getChildAt(0))

View File

@@ -178,7 +178,11 @@ class ConversationSettingsRepository(
fun block(recipientId: RecipientId) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
RecipientUtil.blockNonGroup(context, recipient)
if (recipient.isGroup) {
RecipientUtil.block(context, recipient)
} else {
RecipientUtil.blockNonGroup(context, recipient)
}
}
}

View File

@@ -16,6 +16,7 @@ data class ConversationSettingsState(
val canModifyBlockedState: Boolean = false,
val sharedMedia: Cursor? = null,
val sharedMediaIds: List<Long> = listOf(),
val displayInternalRecipientDetails: Boolean = false,
private val sharedMediaLoaded: Boolean = false,
private val specificSettingsState: SpecificSettingsState,
) {
@@ -49,8 +50,7 @@ sealed class SpecificSettingsState {
val selfHasGroups: Boolean = false,
val canShowMoreGroupsInCommon: Boolean = false,
val groupsInCommonExpanded: Boolean = false,
val contactLinkState: ContactLinkState = ContactLinkState.NONE,
val displayInternalRecipientDetails: Boolean
val contactLinkState: ContactLinkState = ContactLinkState.NONE
) : SpecificSettingsState() {
override val isLoaded: Boolean = true

View File

@@ -71,7 +71,8 @@ sealed class ConversationSettingsViewModel(
state.copy(
sharedMedia = cursor.orNull(),
sharedMediaIds = ids,
sharedMediaLoaded = true
sharedMediaLoaded = true,
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
)
} else {
cursor.orNull().ensureClosed()
@@ -121,9 +122,7 @@ sealed class ConversationSettingsViewModel(
private val repository: ConversationSettingsRepository
) : ConversationSettingsViewModel(
repository,
SpecificSettingsState.RecipientSettingsState(
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
)
SpecificSettingsState.RecipientSettingsState()
) {
private val liveRecipient = Recipient.live(recipientId)

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.content.Context
import android.graphics.Color
import android.text.TextUtils
import android.widget.Toast
@@ -7,6 +8,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -14,6 +16,8 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -55,79 +59,103 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
summary = DSLSettingsText.from(recipient.id.serialize())
)
val uuid = recipient.uuid.transform(UUID::toString).or("null")
if (!recipient.isGroup) {
val uuid = recipient.uuid.transform(UUID::toString).or("null")
longClickPref(
title = DSLSettingsText.from("UUID"),
summary = DSLSettingsText.from(uuid),
onLongClick = { copyToClipboard(uuid) }
)
}
if (state.groupId != null) {
val groupId: String = state.groupId.toString()
longClickPref(
title = DSLSettingsText.from("GroupId"),
summary = DSLSettingsText.from(groupId),
onLongClick = { copyToClipboard(groupId) }
)
}
val threadId: String = if (state.threadId != null) state.threadId.toString() else "N/A"
longClickPref(
title = DSLSettingsText.from("UUID"),
summary = DSLSettingsText.from(uuid),
onLongClick = { copyToClipboard(uuid) }
title = DSLSettingsText.from("ThreadId"),
summary = DSLSettingsText.from(threadId),
onLongClick = { copyToClipboard(threadId) }
)
textPref(
title = DSLSettingsText.from("Profile Name"),
summary = DSLSettingsText.from("[${recipient.profileName.givenName}] [${state.recipient.profileName.familyName}]")
)
if (!recipient.isGroup) {
textPref(
title = DSLSettingsText.from("Profile Name"),
summary = DSLSettingsText.from("[${recipient.profileName.givenName}] [${state.recipient.profileName.familyName}]")
)
val profileKeyBase64 = recipient.profileKey?.let(Base64::encodeBytes) ?: "None"
longClickPref(
title = DSLSettingsText.from("Profile Key (Base64)"),
summary = DSLSettingsText.from(profileKeyBase64),
onLongClick = { copyToClipboard(profileKeyBase64) }
)
val profileKeyBase64 = recipient.profileKey?.let(Base64::encodeBytes) ?: "None"
longClickPref(
title = DSLSettingsText.from("Profile Key (Base64)"),
summary = DSLSettingsText.from(profileKeyBase64),
onLongClick = { copyToClipboard(profileKeyBase64) }
)
val profileKeyHex = recipient.profileKey?.let(Hex::toStringCondensed) ?: ""
longClickPref(
title = DSLSettingsText.from("Profile Key (Hex)"),
summary = DSLSettingsText.from(profileKeyHex),
onLongClick = { copyToClipboard(profileKeyHex) }
)
val profileKeyHex = recipient.profileKey?.let(Hex::toStringCondensed) ?: ""
longClickPref(
title = DSLSettingsText.from("Profile Key (Hex)"),
summary = DSLSettingsText.from(profileKeyHex),
onLongClick = { copyToClipboard(profileKeyHex) }
)
textPref(
title = DSLSettingsText.from("Sealed Sender Mode"),
summary = DSLSettingsText.from(recipient.unidentifiedAccessMode.toString())
)
textPref(
title = DSLSettingsText.from("Sealed Sender Mode"),
summary = DSLSettingsText.from(recipient.unidentifiedAccessMode.toString())
)
}
textPref(
title = DSLSettingsText.from("Profile Sharing (AKA \"Whitelisted\")"),
summary = DSLSettingsText.from(recipient.isProfileSharing.toString())
)
textPref(
title = DSLSettingsText.from("Capabilities"),
summary = DSLSettingsText.from(buildCapabilitySpan(recipient))
)
if (!recipient.isGroup) {
textPref(
title = DSLSettingsText.from("Capabilities"),
summary = DSLSettingsText.from(buildCapabilitySpan(recipient))
)
}
sectionHeaderPref(DSLSettingsText.from("Actions"))
if (!recipient.isGroup) {
sectionHeaderPref(DSLSettingsText.from("Actions"))
clickPref(
title = DSLSettingsText.from("Disable Profile Sharing"),
summary = DSLSettingsText.from("Clears profile sharing/whitelisted status, which should cause the Message Request UI to show."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ -> DatabaseFactory.getRecipientDatabase(requireContext()).setProfileSharing(recipient.id, false) }
.show()
}
)
clickPref(
title = DSLSettingsText.from("Disable Profile Sharing"),
summary = DSLSettingsText.from("Clears profile sharing/whitelisted status, which should cause the Message Request UI to show."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ -> DatabaseFactory.getRecipientDatabase(requireContext()).setProfileSharing(recipient.id, false) }
.show()
}
)
clickPref(
title = DSLSettingsText.from("Delete Session"),
summary = DSLSettingsText.from("Deletes the session, essentially guaranteeing an encryption error if they send you a message."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
if (recipient.hasUuid()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireUuid().toString())
clickPref(
title = DSLSettingsText.from("Delete Session"),
summary = DSLSettingsText.from("Deletes the session, essentially guaranteeing an encryption error if they send you a message."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
if (recipient.hasUuid()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireUuid().toString())
}
if (recipient.hasE164()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireE164())
}
}
if (recipient.hasE164()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireE164())
}
}
.show()
}
)
.show()
}
)
}
}
}
@@ -162,17 +190,30 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
val recipientId: RecipientId
) : ViewModel(), RecipientForeverObserver {
private val store = Store(InternalState(Recipient.resolved(recipientId)))
private val store = Store(
InternalState(
recipient = Recipient.resolved(recipientId),
threadId = null,
groupId = null
)
)
val state = store.stateLiveData
val liveRecipient = Recipient.live(recipientId)
init {
liveRecipient.observeForever(this)
SignalExecutors.BOUNDED.execute {
val context: Context = ApplicationDependencies.getApplication()
val threadId: Long? = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId)
val groupId: GroupId? = DatabaseFactory.getGroupDatabase(context).getGroup(recipientId).transform { it.id }.orNull()
store.update { state -> state.copy(threadId = threadId, groupId = groupId) }
}
}
override fun onRecipientChanged(recipient: Recipient) {
store.update { InternalState(recipient) }
store.update { state -> state.copy(recipient = recipient) }
}
override fun onCleared() {
@@ -187,6 +228,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
}
data class InternalState(
val recipient: Recipient
val recipient: Recipient,
val threadId: Long?,
val groupId: GroupId?
)
}

View File

@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import androidx.core.view.ViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
@@ -23,7 +25,8 @@ object AvatarPreference {
class Model(
val recipient: Recipient,
val onAvatarClick: (View) -> Unit
val onAvatarClick: (View) -> Unit,
val onBadgeClick: (Badge) -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient == newItem.recipient
@@ -36,11 +39,23 @@ object AvatarPreference {
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
ViewCompat.setTransitionName(this, "avatar")
setFallbackPhotoProvider(AvatarPreferenceFallbackPhotoProvider())
}
private val badge: BadgeImageView = itemView.findViewById(R.id.bio_preference_badge)
init {
ViewCompat.setTransitionName(avatar.parent as View, "avatar")
}
override fun bind(model: Model) {
badge.setBadgeFromRecipient(model.recipient)
badge.setOnClickListener {
val badge = model.recipient.badges.firstOrNull()
if (badge != null) {
model.onBadgeClick(badge)
}
}
avatar.setAvatar(model.recipient)
avatar.disableQuickContact()
avatar.setOnClickListener { model.onAvatarClick(avatar) }

View File

@@ -1,7 +1,11 @@
package org.thoughtcrime.securesms.components.settings
import androidx.annotation.CallSuper
import androidx.annotation.Px
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.components.settings.models.Button
import org.thoughtcrime.securesms.components.settings.models.Space
import org.thoughtcrime.securesms.components.settings.models.Text
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingModelList
@@ -121,6 +125,35 @@ class DSLConfiguration {
children.add(preference)
}
fun noPadTextPref(title: DSLSettingsText) {
val preference = Text(title)
children.add(Text.Model(preference))
}
fun space(@Px pixels: Int) {
val preference = Space(pixels)
children.add(Space.Model(preference))
}
fun primaryButton(
text: DSLSettingsText,
isEnabled: Boolean = true,
onClick: () -> Unit
) {
val preference = Button.Model.Primary(text, null, isEnabled, onClick)
children.add(preference)
}
fun secondaryButtonNoOutline(
text: DSLSettingsText,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
onClick: () -> Unit
) {
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, onClick)
children.add(preference)
}
fun textPref(
title: DSLSettingsText? = null,
summary: DSLSettingsText? = null

View File

@@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object Button {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model.Primary::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.Primary> }, R.layout.dsl_button_primary))
mappingAdapter.registerFactory(Model.SecondaryNoOutline::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.SecondaryNoOutline> }, R.layout.dsl_button_secondary))
}
sealed class Model<T : Model<T>>(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
val onClick: () -> Unit
) : PreferenceModel<T>(
title = title,
icon = icon,
isEnabled = isEnabled
) {
class Primary(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
onClick: () -> Unit
) : Model<Primary>(title, icon, isEnabled, onClick)
class SecondaryNoOutline(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
onClick: () -> Unit
) : Model<SecondaryNoOutline>(title, icon, isEnabled, onClick)
}
class ViewHolder(itemView: View) : MappingViewHolder<Model<*>>(itemView) {
private val button: MaterialButton = itemView as MaterialButton
override fun bind(model: Model<*>) {
button.text = model.title?.resolve(context)
button.setOnClickListener {
model.onClick()
}
button.icon = model.icon?.resolve(context)
button.isEnabled = model.isEnabled
}
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object IndeterminateLoadingCircle : PreferenceModel<IndeterminateLoadingCircle>() {
override fun areItemsTheSame(newItem: IndeterminateLoadingCircle): Boolean = true
private class ViewHolder(itemView: View) : MappingViewHolder<IndeterminateLoadingCircle>(itemView) {
override fun bind(model: IndeterminateLoadingCircle) = Unit
}
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(IndeterminateLoadingCircle::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.indeterminate_loading_circle_pref))
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import androidx.annotation.Px
import androidx.core.view.updateLayoutParams
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* Adds extra space between elements in a DSL fragment
*/
data class Space(
@Px val pixels: Int
) {
companion object {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_space_preference))
}
}
class Model(val space: Space) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && newItem.space == space
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
override fun bind(model: Model) {
itemView.updateLayoutParams {
height = model.space.pixels
}
}
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.models
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* A Text without any padding, allowing for exact padding to be handed in at runtime.
*/
data class Text(
val text: DSLSettingsText,
) {
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_text_preference))
}
}
class Model(val paddableText: Text) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && newItem.paddableText == paddableText
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val text: TextView = itemView.findViewById(R.id.title)
override fun bind(model: Model) {
text.text = model.paddableText.text.resolve(context)
val clickableSpans = (text.text as? Spanned)?.getSpans(0, text.text.length, ClickableSpan::class.java)
if (clickableSpans?.isEmpty() == false) {
text.movementMethod = LinkMovementMethod.getInstance()
} else {
text.movementMethod = null
}
}
}
}

View File

@@ -106,10 +106,8 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
@Override
public void onResume(@NonNull LifecycleOwner owner) {
if (!mediaBrowser.isConnected()) {
mediaBrowser.connect();
}
mediaBrowser.disconnect();
mediaBrowser.connect();
activity.setVolumeControlStream(AudioManager.STREAM_MUSIC);
}

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