Compare commits

..

157 Commits

Author SHA1 Message Date
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
Alex Hart
556ca5a573 Bump version to 5.24.7 2021-09-23 11:32:51 -03:00
Alex Hart
91645e6adc Revert "Inline MP4 GIF flag."
This reverts commit e2e0caa94a.
2021-09-23 11:17:54 -03:00
Alex Hart
4d6bb95aa4 Bump version to 5.24.6 2021-09-23 10:09:22 -03:00
Alex Hart
55ee68fa2d Updated language translations. 2021-09-23 10:08:38 -03:00
Alex Hart
747bc7c3bf Swap out expiring pinned mobilecoin cert. 2021-09-23 09:48:39 -03:00
Alex Hart
9c17201eaf Bump version to 5.24.5 2021-09-22 16:40:02 -03:00
Alex Hart
a24b3d9a60 Updated language translations. 2021-09-22 16:39:38 -03:00
Alex Hart
c93d882fe1 Don't allow API<23 to display gif videos in conversation list. 2021-09-22 16:21:13 -03:00
Alex Hart
67403a6a9f Bump version to 5.24.4 2021-09-21 17:00:21 -03:00
Alex Hart
5f9e72bb3c Updated language translations. 2021-09-21 16:59:58 -03:00
Greyson Parrelli
091b38ceb8 Use the GIF content type for quoted MP4 GIFs. 2021-09-21 15:52:08 -04:00
Cody Henthorne
83dfb984fb Update to RingRTC v2.13.1 2021-09-21 15:41:01 -04:00
Greyson Parrelli
9f14831fc4 Do not crash on issues with the log database. 2021-09-21 14:16:31 -04:00
Alex Hart
48bfcc9b16 Bump version to 5.24.3 2021-09-21 13:31:24 -03:00
Alex Hart
7028ca9411 Updated language translations. 2021-09-21 13:30:48 -03:00
Cody Henthorne
5175375483 Fix crash when getting update body on main thread. 2021-09-21 11:17:31 -04:00
Greyson Parrelli
e2dbaa605b Fix potential stack overflow when getting identity record. 2021-09-21 09:16:58 -04:00
Alex Hart
93fd6e7a55 Fix issue with media controller lifecycle.
We were connecting and disconnecting in onStart and onStop,
which can get called in different orders depending on what the
system does. This results in sometimes trying to connect to an
already connected media session.
2021-09-21 10:09:43 -03:00
Alex Hart
b070e6962f Remove view from parent before trying to insert into a new container. 2021-09-21 10:03:42 -03:00
Alex Hart
b1d1b7e31e Fix NullPointerException when getting ringtone title. 2021-09-21 09:57:59 -03:00
Alex Hart
1a5ae603d5 Bump version to 5.24.2 2021-09-20 16:25:44 -03:00
Alex Hart
3b42bda63d Updated language translations. 2021-09-20 16:25:44 -03:00
Alex Hart
2f70a71a6c Fix several kotlin formatting issues from bug fixes. 2021-09-20 16:25:44 -03:00
Cody Henthorne
aae368c049 Clear profile upload flag when unregistering. 2021-09-20 16:25:44 -03:00
Alex Hart
dee3d2ff2d Fix restrictive sizing of speed toggle in voice note bar. 2021-09-20 16:25:44 -03:00
Alex Hart
da2e2a99af Remove outdated stableId pattern from ConversationAdapter. 2021-09-20 11:53:47 -03:00
Alex Hart
0c00426c0c Fix internal preference issue with creating a clipboard service. 2021-09-20 11:41:04 -03:00
Alex Hart
ccc96d5bfa Fix gif display when list is changed and view holders are not reused. 2021-09-20 11:19:33 -03:00
Alex Hart
82c12c2f6b Do not allow content to play if no media item is available. 2021-09-20 10:52:48 -03:00
Alex Hart
9416beb4aa Trigger pending transition at right time for video gifs. 2021-09-20 10:35:01 -03:00
Alex Hart
39b80a48c7 Ensure message details video container matches placement of recycler. 2021-09-20 10:22:43 -03:00
Alex Hart
d5491a2e84 Fix vector load crash on Kitkat.
Fixes #11628
2021-09-20 10:19:37 -03:00
Alex Hart
07b19402e6 Fix wallpaper gallery toolbar behaviour.
Fixes #11619
2021-09-20 10:12:59 -03:00
Alex Hart
318b4703f2 Bump version to 5.24.1 2021-09-17 16:15:26 -03:00
Alex Hart
d389697f27 Updated language translations. 2021-09-17 16:14:25 -03:00
Alex Hart
7bcc338a49 Implement radial dial.
Co-authored-by: Alan Evans <alan@signal.org>
2021-09-17 13:09:13 -03:00
Cody Henthorne
ce2c2002c6 Revert thread updates to running inline again. 2021-09-17 11:50:46 -04:00
Greyson Parrelli
d5fbd10406 Create a SignalDataSource class for all of our ExoPlayer needs.
Also fixes an issue around GIF playback within a conversation.
2021-09-17 09:58:11 -04:00
Cody Henthorne
6f6da699a3 Fix groups not showing after pin restore. 2021-09-17 09:56:49 -04:00
Cody Henthorne
62d8c115ba Enable group call notification settings when group ringing is enabled. 2021-09-17 09:53:28 -04:00
Alex Hart
fd01ee2a87 Add stopwatches for a few possible pain points in MediaGallery. 2021-09-16 16:29:51 -03:00
Cody Henthorne
9aa517ad99 Fix UI bugs in dark mode change number flow. 2021-09-16 14:14:38 -04:00
Greyson Parrelli
6c3e1b6a29 Add internal preference to disable storage syncing.
Added to help debug certain scenarios, particularly around working with
emulator snapshots, since storage sync will often bring in state from earlier
snapshots you weren't expecting.
2021-09-16 13:32:25 -04:00
580 changed files with 20609 additions and 4167 deletions

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 = 920
def canonicalVersionName = "5.24.0"
def canonicalVersionCode = 937
def canonicalVersionName = "5.24.17"
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 "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_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
@@ -341,7 +347,7 @@ android {
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new 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\""
@@ -451,6 +457,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,6 +548,8 @@ 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

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

@@ -191,7 +191,7 @@ 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();
SignalExecutors.BOUNDED.execute(() -> {
@@ -203,6 +203,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
KeyCachingService.onAppForegrounded(this);
ApplicationDependencies.getShakeToReport().enable();
checkBuildExpiration();
ApplicationDependencies.getDeadlockDetector().start();
});
Log.d(TAG, "onStart() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -213,8 +214,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 +260,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() {

View File

@@ -66,7 +66,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

@@ -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,63 @@
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 androidx.lifecycle.Lifecycle
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import org.signal.core.util.logging.Log
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.ViewUtil
import org.thoughtcrime.securesms.util.visible
private val TAG = Log.tag(BadgeImageView::class.java)
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
val lifecycle = ViewUtil.getActivityLifecycle(this)
if (lifecycle?.currentState == Lifecycle.State.DESTROYED) {
return
}
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)
}
}
}

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,35 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
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.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
object Badges {
fun DSLConfiguration.displayBadges(context: Context, badges: List<Badge>, selectedBadge: Badge? = null) {
badges
.map { Badge.Model(it, it == selectedBadge) }
.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
}
}

View File

@@ -0,0 +1,106 @@
package org.thoughtcrime.securesms.badges.glide
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import androidx.annotation.VisibleForTesting
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.lang.IllegalArgumentException
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)".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) {
SMALL("small"),
MEDIUM("medium"),
LARGE("large"),
XLARGE("xlarge");
companion object {
fun fromInteger(integer: Int): Size {
return when (integer) {
0 -> SMALL
1 -> MEDIUM
2 -> LARGE
3 -> XLARGE
else -> LARGE
}
}
}
}
companion object {
private const val PADDING = 1
@VisibleForTesting
fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
val scaleFactor: Int = when (density) {
"ldpi" -> 75
"mdpi" -> 100
"hdpi" -> 150
"xhdpi" -> 200
"xxhdpi" -> 300
"xxxhdpi" -> 400
else -> throw IllegalArgumentException("Unexpected density $density")
}
val smallLength = 8 * scaleFactor / 100
val mediumLength = 12 * scaleFactor / 100
val largeLength = 18 * scaleFactor / 100
val xlargeLength = 80 * scaleFactor / 100
val sideLength: Int = when (size) {
Size.SMALL -> smallLength
Size.MEDIUM -> mediumLength
Size.LARGE -> largeLength
Size.XLARGE -> xlargeLength
}
val lightOffset: Int = when (size) {
Size.LARGE -> PADDING
Size.MEDIUM -> (largeLength + PADDING * 2) * 2 + PADDING
Size.SMALL -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + PADDING
Size.XLARGE -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + (smallLength + PADDING * 2) * 2 + PADDING
}
val darkOffset = if (isDarkTheme) {
when (size) {
Size.XLARGE -> 0
else -> sideLength + PADDING * 2
}
} else {
0
}
return Rect(
lightOffset + darkOffset,
PADDING,
lightOffset + darkOffset + sideLength,
sideLength + PADDING
)
}
}
}

View File

@@ -0,0 +1,187 @@
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) -> 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
) : 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
}
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)
}
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()) 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))
}
}
@Parcelize
data class ImageSet(
val ldpi: String,
val mdpi: String,
val hdpi: String,
val xhdpi: String,
val xxhdpi: String,
val xxxhdpi: String
) : Parcelable {
fun getByDensity(density: String): String {
return when (density) {
"ldpi" -> ldpi
"mdpi" -> mdpi
"hdpi" -> hdpi
"xhdpi" -> xhdpi
"xxhdpi" -> xxhdpi
"xxxhdpi" -> xxxhdpi
else -> xhdpi
}
}
}
}

View File

@@ -0,0 +1,54 @@
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 newItem.badge?.id == badge?.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
}
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
return newItem.badge?.id == badge?.id
}
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
}
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) {
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,85 @@
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()
}
}
viewModel.state.observe(viewLifecycleOwner) { state ->
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
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,80 @@
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.configure
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())) })
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _ ->
if (badge.isExpired()) {
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(requireContext(), state.allUnlockedBadges)
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,19 @@
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 hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
enum class Stage {
INIT,
READY,
UPDATING
}
}

View File

@@ -0,0 +1,61 @@
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.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : 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
)
}
}
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) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository)))
}
}
}

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

@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ThemeUtil;
@@ -228,55 +229,63 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
bodyView.setVisibility(GONE);
mediaDescriptionText.setVisibility(VISIBLE);
List<Slide> audioSlides = Stream.of(attachments.getSlides()).filter(Slide::hasAudio).limit(1).toList();
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
List<Slide> imageSlides = Stream.of(attachments.getSlides()).filter(Slide::hasImage).limit(1).toList();
List<Slide> videoSlides = Stream.of(attachments.getSlides()).filter(Slide::hasVideo).limit(1).toList();
List<Slide> stickerSlides = Stream.of(attachments.getSlides()).filter(Slide::hasSticker).limit(1).toList();
List<Slide> viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList();
Slide audioSlide = attachments.getSlides().stream().filter(Slide::hasAudio).findFirst().orElse(null);
Slide documentSlide = attachments.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide imageSlide = attachments.getSlides().stream().filter(Slide::hasImage).findFirst().orElse(null);
Slide videoSlide = attachments.getSlides().stream().filter(Slide::hasVideo).findFirst().orElse(null);
Slide stickerSlide = attachments.getSlides().stream().filter(Slide::hasSticker).findFirst().orElse(null);
Slide viewOnceSlide = attachments.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
// Given that most types have images, we specifically check images last
if (!viewOnceSlides.isEmpty()) {
if (viewOnceSlide != null) {
mediaDescriptionText.setText(R.string.QuoteView_view_once_media);
} else if (!audioSlides.isEmpty()) {
} else if (audioSlide != null) {
mediaDescriptionText.setText(R.string.QuoteView_audio);
} else if (!documentSlides.isEmpty()) {
} else if (documentSlide != null) {
mediaDescriptionText.setVisibility(GONE);
} else if (!videoSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_video);
} else if (!stickerSlides.isEmpty()) {
} else if (videoSlide != null) {
if (videoSlide.isVideoGif()) {
mediaDescriptionText.setText(R.string.QuoteView_gif);
} else {
mediaDescriptionText.setText(R.string.QuoteView_video);
}
} else if (stickerSlide != null) {
mediaDescriptionText.setText(R.string.QuoteView_sticker);
} else if (!imageSlides.isEmpty()) {
mediaDescriptionText.setText(R.string.QuoteView_photo);
} else if (imageSlide != null) {
if (MediaUtil.isGif(imageSlide.getContentType())) {
mediaDescriptionText.setText(R.string.QuoteView_gif);
} else {
mediaDescriptionText.setText(R.string.QuoteView_photo);
}
}
}
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull SlideDeck slideDeck) {
List<Slide> imageVideoSlides = Stream.of(slideDeck.getSlides()).filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).limit(1).toList();
List<Slide> documentSlides = Stream.of(attachments.getSlides()).filter(Slide::hasDocument).limit(1).toList();
List<Slide> viewOnceSlides = Stream.of(attachments.getSlides()).filter(Slide::hasViewOnce).limit(1).toList();
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
attachmentVideoOverlayView.setVisibility(GONE);
if (!viewOnceSlides.isEmpty()) {
if (viewOnceSlide != null) {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
} else if (!imageVideoSlides.isEmpty() && imageVideoSlides.get(0).getUri() != null) {
} else if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundResource(R.drawable.dismiss_background);
if (imageVideoSlides.get(0).hasVideo()) {
if (imageVideoSlide.hasVideo() && !imageVideoSlide.isVideoGif()) {
attachmentVideoOverlayView.setVisibility(VISIBLE);
}
glideRequests.load(new DecryptableUri(imageVideoSlides.get(0).getUri()))
glideRequests.load(new DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
.override(getContext().getResources().getDimensionPixelSize(R.dimen.quote_thumb_size))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (!documentSlides.isEmpty()){
} else if (documentSlide != null){
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(VISIBLE);
attachmentNameView.setText(documentSlides.get(0).getFileName().or(""));
attachmentNameView.setText(documentSlide.getFileName().or(""));
} else {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);

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

@@ -10,6 +10,7 @@ import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.graphics.drawable.shapes.Shape;
import android.net.Uri;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
@@ -438,8 +439,10 @@ public class ThumbnailView extends FrameLayout {
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transition(withCrossFade()), fit);
if (slide.isInProgress()) return request;
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
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));
}
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {

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

@@ -19,12 +19,16 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
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)
@@ -41,7 +45,7 @@ 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)
setText(text, bufferType ?: BufferType.SPANNABLE)
}
}
}

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,23 @@ 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.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 +29,23 @@ 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(), 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)
}
@@ -79,8 +102,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)
@@ -109,6 +141,13 @@ class AppSettingsActivity : DSLSettingsActivity() {
}
}
private fun warmDonationViewModels() {
if (FeatureFlags.donorBadges()) {
subscribeViewModel
boostViewModel
}
}
private enum class StartLocation(val code: Int) {
HOME(0),
BACKUPS(1),

View File

@@ -4,7 +4,9 @@ import android.view.View
import android.widget.TextView
import androidx.lifecycle.ViewModelProvider
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
@@ -129,11 +131,33 @@ 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()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
onClick = {
findNavController()
.navigate(
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
)
}
)
// 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()
@@ -162,6 +186,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 +196,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

@@ -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
@@ -107,6 +108,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
sectionHeaderPref(R.string.preferences__internal_storage_service)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_disable_storage_service),
summary = DSLSettingsText.from(R.string.preferences__internal_disable_storage_service_description),
isChecked = state.disableStorageService,
onClick = {
viewModel.setDisableStorageService(!state.disableStorageService)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_force_storage_service_sync),
summary = DSLSettingsText.from(R.string.preferences__internal_force_storage_service_sync_description),
@@ -225,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)
@@ -314,12 +332,12 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
.setPositiveButton(
"Copy"
) { _: DialogInterface?, _: Int ->
val context: Context = ApplicationDependencies.getApplication()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
SimpleTask.run<Any?>(
SignalExecutors.UNBOUNDED,
{
val context: Context = ApplicationDependencies.getApplication()
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val tsv = DataExportUtil.createTsv()
val clip = ClipData.newPlainText(context.getString(R.string.app_name), tsv)
clipboard.setPrimaryClip(clip)

View File

@@ -17,4 +17,5 @@ data class InternalSettingsState(
val emojiVersion: EmojiFiles.Version?,
val removeSenderKeyMinimium: Boolean,
val delayResends: Boolean,
val disableStorageService: Boolean,
)

View File

@@ -30,6 +30,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setDisableStorageService(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.DISABLE_STORAGE_SERVICE, enabled)
refresh()
}
fun setGv2DoNotCreateGv2Groups(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.GV2_DO_NOT_CREATE_GV2, enabled)
refresh()
@@ -103,7 +108,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
emojiVersion = null,
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
delayResends = SignalStore.internalValues().delayResends()
delayResends = SignalStore.internalValues().delayResends(),
disableStorageService = SignalStore.internalValues().storageServiceDisabled()
)
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {

View File

@@ -30,6 +30,7 @@ class AdvancedPrivacySettingsViewModel(
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.SUCCESS -> {
TextSecurePreferences.setPushRegistered(ApplicationDependencies.getApplication(), false)
SignalStore.registrationValues().clearRegistrationComplete()
SignalStore.registrationValues().clearHasUploadedProfile()
}
AdvancedPrivacySettingsRepository.DisablePushMessagesResult.NETWORK_ERROR -> {
singleEvents.postValue(Event.DISABLE_PUSH_FAILED)

View File

@@ -0,0 +1,15 @@
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()
object SubscriptionCancelled : DonationEvent()
}

View File

@@ -0,0 +1,55 @@
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 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.BuildConfig
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
private val stripeApi = StripeApi(configuration, 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)
}
}
}
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
return ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.subscription.Subscription
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 {
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
listOf()
}
}

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,146 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.text.SpannableStringBuilder
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
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.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() {
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
private val lifecycleDisposable = LifecycleDisposable()
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)
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 -> Log.w(TAG, "Google Pay error", event.throwable)
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
}
}
}
private fun getConfiguration(state: BoostState): DSLConfiguration {
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())
}
)
)
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))
}
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,24 @@
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,
PAYMENT_PIPELINE,
}
}

View File

@@ -0,0 +1,168 @@
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.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
private var boostToPurchase: Boost? = null
override fun onCleared() {
disposables.clear()
}
init {
val currencyObservable = SignalStore.donationsValues().observableCurrency
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 = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
onComplete = {
// Now we need to do the whole query for a token, submit token rigamarole
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
}
)
}
}
override fun onError() {
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
}
}
)
}
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,38 @@
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()
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,68 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
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 : ViewModel() {
private val store = Store(SetCurrencyState())
val state: LiveData<SetCurrencyState> = store.stateLiveData
init {
val defaultCurrency = SignalStore.donationsValues().getCurrency()
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) }
SignalStore.donationsValues().setCurrency(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()))
}
}
}
}

View File

@@ -0,0 +1,73 @@
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
) : 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
}
}
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.subscription.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,130 @@
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.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.util.LifecycleDisposable
/**
* 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())
}
)
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)
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.activeSubscription != null) {
customPref(
ActiveSubscriptionPreference.Model(
subscription = state.activeSubscription,
onAddBoostClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
}
)
)
dividerPref()
}
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,9 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.subscription.Subscription
data class ManageDonationsState(
val featuredBadge: Badge? = null,
val activeSubscription: Subscription? = null
)

View File

@@ -0,0 +1,58 @@
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.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.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
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
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
state.copy(featuredBadge = self.featuredBadge)
}
}
override fun onCleared() {
disposables.clear()
}
fun refresh() {
disposables.clear()
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
onComplete = {
store.update { it.copy(activeSubscription = null) }
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
},
onError = {
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
}
)
}
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,44 @@
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
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,175 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.text.SpannableStringBuilder
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.subscription.DonationEvent
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.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
/**
* UX for creating and changing a subscription
*/
class SubscribeFragment : DSLSettingsFragment() {
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())
}
)
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgePreview.register(adapter)
CurrencySelection.register(adapter)
Subscription.register(adapter)
GooglePayButton.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe {
when (it) {
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
}
}
}
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
return configure {
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
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 = state.stage == SubscribeState.Stage.READY,
onClick = {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
}
)
)
state.subscriptions.forEach {
customPref(
Subscription.Model(
subscription = it,
isSelected = state.selectedSubscription == it,
isEnabled = state.stage == SubscribeState.Stage.READY,
isActive = state.activeSubscription == it,
onClick = { viewModel.setSelectedSubscription(it) }
)
)
}
if (state.activeSubscription != null) {
primaryButton(
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
onClick = {
// TODO [alex] -- Dunno what the update process requires.
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
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 = state.stage == SubscribeState.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()
}
private fun onPaymentConfirmed(badge: Badge) {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
}
private fun onSubscriptionCancelled() {
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
}
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,22 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.subscription.Subscription
data class SubscribeState(
val previewBadge: Badge? = null,
val currencySelection: CurrencySelection = CurrencySelection("USD"),
val subscriptions: List<Subscription> = listOf(),
val selectedSubscription: Subscription? = null,
val activeSubscription: Subscription? = null,
val isGooglePayAvailable: Boolean = false,
val stage: Stage = Stage.INIT
) {
enum class Stage {
INIT,
READY,
PAYMENT_PIPELINE,
CANCELLING
}
}

View File

@@ -0,0 +1,136 @@
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.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.libsignal.util.guava.Optional
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
private var subscriptionToPurchase: Subscription? = null
override fun onCleared() {
disposables.clear()
}
fun refresh() {
disposables.clear()
val currency = SignalStore.donationsValues().getCurrency()
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
.map { Optional.of(it) }
.defaultIfEmpty(Optional.absent())
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
store.update {
it.copy(
subscriptions = subs,
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
activeSubscription = active.orNull(),
stage = SubscribeState.Stage.READY
)
}
}
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
)
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
}
fun cancel() {
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
// TODO [alex] -- cancel api call
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
donationPaymentRepository.onActivityResult(
requestCode, resultCode, data, this.fetchTokenRequestCode,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
val subscription = subscriptionToPurchase
subscriptionToPurchase = null
if (subscription != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
onComplete = {
// Now we need to do the whole query for a token, submit token rigamarole
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
}
)
}
}
override fun onError() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
}
}
)
}
fun requestTokenFromGooglePay() {
val snapshot = store.state
if (snapshot.selectedSubscription == null) {
return
}
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
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,61 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
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 com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
private lateinit var displayOnProfileSwitch: SwitchMaterial
private lateinit var heading: TextView
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?) {
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
badgeView.setBadge(args.badge)
badgeName.text = args.badge.name
displayOnProfileSwitch.isChecked = true
if (args.isBoost) {
presentBoostCopy()
} else {
presentSubscriptionCopy()
}
done.setOnClickListener { dismissAllowingStateLoss() }
}
override fun onDismiss(dialog: DialogInterface) {
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
}
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

@@ -10,6 +10,7 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -19,6 +20,9 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.RingtoneUtil
import java.lang.NullPointerException
private val TAG = Log.tag(CustomNotificationsSettingsFragment::class.java)
class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomNotificationsDialogFragment__custom_notifications) {
@@ -135,7 +139,12 @@ class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomN
} else {
val tone = RingtoneUtil.getRingtone(requireContext(), ringtone)
if (tone != null) {
return tone.getTitle(context)
return try {
tone.getTitle(context)
} catch (e: NullPointerException) {
Log.w(TAG, "Could not get correct title for ringtone.", e)
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
}
}
}
return context.getString(R.string.CustomNotificationsDialogFragment__default)

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.livedata.Store
class CustomNotificationsSettingsViewModel(
@@ -42,7 +43,7 @@ class CustomNotificationsSettingsViewModel(
RecipientDatabase.VibrateState.ENABLED -> true
RecipientDatabase.VibrateState.DISABLED -> false
},
showCallingOptions = !recipient.isGroup && recipient.isRegistered,
showCallingOptions = recipient.isRegistered && (!recipient.isGroup || FeatureFlags.groupCallRinging()),
callSound = recipient.callRingtone,
callVibrateState = recipient.callVibrate
)

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,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

@@ -104,20 +104,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
return voiceNotePlayerViewState;
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (!mediaBrowser.isConnected()) {
mediaBrowser.connect();
}
}
@Override
public void onResume(@NonNull LifecycleOwner owner) {
mediaBrowser.disconnect();
mediaBrowser.connect();
activity.setVolumeControlStream(AudioManager.STREAM_MUSIC);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
public void onPause(@NonNull LifecycleOwner owner) {
clearProgressEventHandler();
if (MediaControllerCompat.getMediaController(activity) != null) {

View File

@@ -5,12 +5,12 @@ import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultLoadControl
import com.google.android.exoplayer2.ForwardingPlayer
import com.google.android.exoplayer2.SimpleExoPlayer
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
class VoiceNotePlayer @JvmOverloads constructor(
context: Context,
val internalPlayer: SimpleExoPlayer = SimpleExoPlayer.Builder(context)
.setMediaSourceFactory(AttachmentMediaSourceFactory(context))
.setMediaSourceFactory(SignalMediaSourceFactory(context))
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)

View File

@@ -35,6 +35,7 @@ class VoiceNotePlayerView @JvmOverloads constructor(
private val playPauseToggleView: LottieAnimationView
private val infoView: TextView
private val durationView: TextView
private val speedView: PlaybackSpeedToggleTextView
private val closeButton: View
@@ -49,6 +50,7 @@ class VoiceNotePlayerView @JvmOverloads constructor(
playPauseToggleView = findViewById(R.id.voice_note_player_play_pause_toggle)
infoView = findViewById(R.id.voice_note_player_info)
durationView = findViewById(R.id.voice_note_player_duration)
speedView = findViewById(R.id.voice_note_player_speed)
closeButton = findViewById(R.id.voice_note_player_close)
@@ -116,7 +118,11 @@ class VoiceNotePlayerView @JvmOverloads constructor(
animateToggleToPause()
}
infoView.text = context.getString(R.string.VoiceNotePlayerView__s_dot_s, state.name, formatDuration(state.playbackDuration - state.playbackPosition))
if (infoView.text != state.name) {
infoView.text = state.name
}
durationView.text = context.getString(R.string.VoiceNotePlayerView__dot_s, formatDuration(state.playbackDuration - state.playbackPosition))
speedView.setCurrentSpeed(state.playbackSpeed)
}

View File

@@ -221,7 +221,7 @@ data class CallParticipantsState(
localRenderState = localRenderState,
showVideoForOutgoing = newShowVideoForOutgoing,
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
ringGroup = webRtcViewModel.shouldRingGroup(),
ringGroup = webRtcViewModel.ringGroup,
isInOutgoingRingingMode = isInOutgoingRingingMode,
ringerRecipient = webRtcViewModel.ringerRecipient
)

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.media.AudioManager;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
@@ -10,11 +9,9 @@ import androidx.core.util.Consumer;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.identity.IdentityRecordList;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.util.Collections;
import java.util.List;
@@ -22,21 +19,9 @@ import java.util.List;
class WebRtcCallRepository {
private final Context context;
private final AudioManager audioManager;
WebRtcCallRepository(@NonNull Context context) {
this.context = context;
this.audioManager = ServiceUtil.getAudioManager(ApplicationDependencies.getApplication());
}
@NonNull WebRtcAudioOutput getAudioOutput() {
if (audioManager.isBluetoothScoOn()) {
return WebRtcAudioOutput.HEADSET;
} else if (audioManager.isSpeakerphoneOn()) {
return WebRtcAudioOutput.SPEAKER;
} else {
return WebRtcAudioOutput.HANDSET;
}
}
@WorkerThread

View File

@@ -19,7 +19,6 @@ import com.annimon.stream.Stream;
import org.signal.core.util.ThreadUtil;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.CallParticipant;
@@ -35,11 +34,13 @@ import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class WebRtcCallViewModel extends ViewModel {
@@ -252,9 +253,9 @@ public class WebRtcCallViewModel extends ViewModel {
webRtcViewModel.isRemoteVideoEnabled(),
webRtcViewModel.isRemoteVideoOffer(),
localParticipant.isMoreThanOneCameraAvailable(),
webRtcViewModel.isBluetoothAvailable(),
Util.hasItems(webRtcViewModel.getRemoteParticipants()),
repository.getAudioOutput(),
webRtcViewModel.getActiveDevice(),
webRtcViewModel.getAvailableDevices(),
webRtcViewModel.getRemoteDevicesCount().orElse(0),
webRtcViewModel.getParticipantLimit());
@@ -314,9 +315,9 @@ public class WebRtcCallViewModel extends ViewModel {
boolean isRemoteVideoEnabled,
boolean isRemoteVideoOffer,
boolean isMoreThanOneCameraAvailable,
boolean isBluetoothAvailable,
boolean hasAtLeastOneRemote,
@NonNull WebRtcAudioOutput audioOutput,
@NonNull SignalAudioManager.AudioDevice activeDevice,
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices,
long remoteDevicesCount,
@Nullable Long participantLimit)
{
@@ -373,14 +374,14 @@ public class WebRtcCallViewModel extends ViewModel {
webRtcControls.setValue(new WebRtcControls(isLocalVideoEnabled,
isRemoteVideoEnabled || isRemoteVideoOffer,
isMoreThanOneCameraAvailable,
isBluetoothAvailable,
Boolean.TRUE.equals(isInPipMode.getValue()),
hasAtLeastOneRemote,
callState,
groupCallState,
audioOutput,
participantLimit,
WebRtcControls.FoldableState.flat()));
WebRtcControls.FoldableState.flat(),
activeDevice,
availableDevices));
}
private @NonNull WebRtcControls updateControlsFoldableState(@NonNull WebRtcControls.FoldableState foldableState, @NonNull WebRtcControls controls) {

View File

@@ -9,65 +9,90 @@ import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import java.util.Set;
import static java.util.Collections.emptySet;
public final class WebRtcControls {
public static final WebRtcControls NONE = new WebRtcControls();
public static final WebRtcControls PIP = new WebRtcControls(false, false, false, false, true, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat());
public static final WebRtcControls PIP = new WebRtcControls(false,
false,
false,
true,
false,
CallState.NONE,
GroupCallState.NONE,
null,
FoldableState.flat(),
SignalAudioManager.AudioDevice.NONE,
emptySet());
private final boolean isRemoteVideoEnabled;
private final boolean isLocalVideoEnabled;
private final boolean isMoreThanOneCameraAvailable;
private final boolean isBluetoothAvailable;
private final boolean isInPipMode;
private final boolean hasAtLeastOneRemote;
private final CallState callState;
private final GroupCallState groupCallState;
private final WebRtcAudioOutput audioOutput;
private final Long participantLimit;
private final FoldableState foldableState;
private final boolean isRemoteVideoEnabled;
private final boolean isLocalVideoEnabled;
private final boolean isMoreThanOneCameraAvailable;
private final boolean isInPipMode;
private final boolean hasAtLeastOneRemote;
private final CallState callState;
private final GroupCallState groupCallState;
private final Long participantLimit;
private final FoldableState foldableState;
private final SignalAudioManager.AudioDevice activeDevice;
private final Set<SignalAudioManager.AudioDevice> availableDevices;
private WebRtcControls() {
this(false, false, false, false, false, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat());
this(false,
false,
false,
false,
false,
CallState.NONE,
GroupCallState.NONE,
null,
FoldableState.flat(),
SignalAudioManager.AudioDevice.NONE,
emptySet());
}
WebRtcControls(boolean isLocalVideoEnabled,
boolean isRemoteVideoEnabled,
boolean isMoreThanOneCameraAvailable,
boolean isBluetoothAvailable,
boolean isInPipMode,
boolean hasAtLeastOneRemote,
@NonNull CallState callState,
@NonNull GroupCallState groupCallState,
@NonNull WebRtcAudioOutput audioOutput,
@Nullable Long participantLimit,
@NonNull FoldableState foldableState)
@NonNull FoldableState foldableState,
@NonNull SignalAudioManager.AudioDevice activeDevice,
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices)
{
this.isLocalVideoEnabled = isLocalVideoEnabled;
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
this.isBluetoothAvailable = isBluetoothAvailable;
this.isMoreThanOneCameraAvailable = isMoreThanOneCameraAvailable;
this.isInPipMode = isInPipMode;
this.hasAtLeastOneRemote = hasAtLeastOneRemote;
this.callState = callState;
this.groupCallState = groupCallState;
this.audioOutput = audioOutput;
this.participantLimit = participantLimit;
this.foldableState = foldableState;
this.activeDevice = activeDevice;
this.availableDevices = availableDevices;
}
public @NonNull WebRtcControls withFoldableState(FoldableState foldableState) {
return new WebRtcControls(isLocalVideoEnabled,
isRemoteVideoEnabled,
isMoreThanOneCameraAvailable,
isBluetoothAvailable,
isInPipMode,
hasAtLeastOneRemote,
callState,
groupCallState,
audioOutput,
participantLimit,
foldableState);
foldableState,
activeDevice,
availableDevices);
}
boolean displayErrorControls() {
@@ -129,7 +154,7 @@ public final class WebRtcControls {
}
boolean displayAudioToggle() {
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || isBluetoothAvailable);
return (isPreJoin() || isAtLeastOutgoing()) && (!isLocalVideoEnabled || enableHeadsetInAudioToggle());
}
boolean displayCameraToggle() {
@@ -153,7 +178,7 @@ public final class WebRtcControls {
}
boolean enableHeadsetInAudioToggle() {
return isBluetoothAvailable;
return availableDevices.contains(SignalAudioManager.AudioDevice.BLUETOOTH);
}
boolean isFadeOutEnabled() {
@@ -173,7 +198,14 @@ public final class WebRtcControls {
}
@NonNull WebRtcAudioOutput getAudioOutput() {
return audioOutput;
switch (activeDevice) {
case SPEAKER_PHONE:
return WebRtcAudioOutput.SPEAKER;
case BLUETOOTH:
return WebRtcAudioOutput.HEADSET;
default:
return WebRtcAudioOutput.HANDSET;
}
}
boolean showSmallHeader() {

View File

@@ -5,7 +5,6 @@ import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.CheckBox;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
@@ -14,6 +13,7 @@ import androidx.constraintlayout.widget.ConstraintLayout;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -37,16 +37,17 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
private TextView labelView;
private CheckBox checkBox;
private View smsTag;
private BadgeImageView badge;
private String number;
private String chipName;
private int contactType;
private String contactName;
private String contactNumber;
private String contactLabel;
private String contactAbout;
private LiveRecipient recipient;
private GlideRequests glideRequests;
private String number;
private String chipName;
private int contactType;
private String contactName;
private String contactNumber;
private String contactLabel;
private String contactAbout;
private LiveRecipient recipient;
private GlideRequests glideRequests;
public ContactSelectionListItem(Context context) {
super(context);
@@ -65,6 +66,7 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
this.nameView = findViewById(R.id.name);
this.checkBox = findViewById(R.id.check_box);
this.smsTag = findViewById(R.id.sms_tag);
this.badge = findViewById(R.id.contact_badge);
ViewUtil.setTextViewGravityStart(this.nameView, getContext());
}
@@ -118,6 +120,8 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
}
this.checkBox.setVisibility(checkboxVisible ? View.VISIBLE : View.GONE);
badge.setBadgeFromRecipient(recipientSnapshot);
}
public void setChecked(boolean selected, boolean animate) {
@@ -229,6 +233,7 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
contactPhotoImage.setAvatar(glideRequests, recipient, false);
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
badge.setBadgeFromRecipient(recipient);
} else {
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());
}

View File

@@ -66,8 +66,8 @@ class ContactDiscoveryV2 {
KeyStore iasKeyStore = getIasKeyStore(context);
try {
Map<String, UUID> results = accountManager.getRegisteredUsers(iasKeyStore, sanitizedNumbers, BuildConfig.CDS_MRENCLAVE);
FuzzyPhoneNumberHelper.OutputResultV2 outputResult = FuzzyPhoneNumberHelper.generateOutputV2(results, inputResult);
Map<String, UUID> results = accountManager.getRegisteredUsers(iasKeyStore, sanitizedNumbers, BuildConfig.CDS_MRENCLAVE);
FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(results, inputResult);
return new DirectoryResult(outputResult.getNumbers(), outputResult.getRewrites(), ignoredNumbers);
} catch (SignatureException | UnauthenticatedQuoteException | UnauthenticatedResponseException | Quote.InvalidQuoteFormatException |InvalidKeyException e) {

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