Compare commits

..

175 Commits
v ... v5.21.1

Author SHA1 Message Date
Cody Henthorne
8e94ced7b6 Bump version to 5.21.1 2021-08-13 17:50:08 -04:00
Cody Henthorne
ffd86a96da Updated language translations. 2021-08-13 17:47:25 -04:00
Lucio Maciel
d4cabce876 Fix crash when getLayout() is null. 2021-08-13 18:39:06 -03:00
Cody Henthorne
a5790edb2b Bump version to 5.21.0 2021-08-13 14:17:32 -04:00
Cody Henthorne
d247e2eabe Updated language translations. 2021-08-13 14:09:42 -04:00
Cody Henthorne
f4d6de466b Fix long SMS send with no service failure loop. 2021-08-13 13:58:38 -04:00
Cody Henthorne
0838c0be27 Fix crash when sending media message as first message in conversation. 2021-08-13 13:58:38 -04:00
Alex Hart
7448183ff4 Update multi-forward work with tweaks from design. 2021-08-13 13:58:38 -04:00
Lucio Maciel
8e2a265cf3 Update emoji search index on system locale changes. 2021-08-13 13:58:38 -04:00
Cody Henthorne
8802cebb64 Prevent constantly requesting new video resolutions in group calls. 2021-08-13 13:58:38 -04:00
Lucio Maciel
0c6fe8bea3 Fix crash when encountering SMS calculate length security exception. 2021-08-13 13:58:38 -04:00
Alex Hart
49334ffd42 Implement proper mentions support for multiforward. 2021-08-13 13:58:38 -04:00
Lucio Maciel
4702ab1aeb Implement better detection of text only messages. 2021-08-13 13:58:38 -04:00
Lucio Maciel
fe8fcb1394 Implement single line timestamps on conversation items. 2021-08-13 13:58:38 -04:00
Alex Hart
dc1e56de4e Implement new bottom fragment UX for multiforward. 2021-08-13 13:58:38 -04:00
Jim Gustafson
561cca5208 Update RingRTC to v2.10.8 2021-08-13 13:58:38 -04:00
Alex Hart
a291732c1a Check if already connected before connecting. 2021-08-13 13:58:38 -04:00
Alex Hart
28abc1e4ff Implement new Multiselect UX and groundwork for Multiforward. 2021-08-13 13:58:38 -04:00
Cody Henthorne
655e43a079 Update call UI to new designs. 2021-08-10 16:27:52 -04:00
Cody Henthorne
94b9a458e7 Bump version to 5.20.4 2021-08-10 16:27:31 -04:00
Cody Henthorne
bb75730315 Updated language translations. 2021-08-10 16:21:59 -04:00
Alex Hart
824a8ac5f2 Fix RuntimeException during call initialization. 2021-08-10 16:08:55 -04:00
Cody Henthorne
3baf10f0e9 Fix bug with not showing entire long message when it contains no mentions. 2021-08-10 12:02:35 -04:00
Cody Henthorne
cfab195e90 Bump version to 5.20.3 2021-08-09 14:40:04 -04:00
Cody Henthorne
503d7c77a0 Updated language translations. 2021-08-09 14:36:45 -04:00
Cody Henthorne
f99ff32947 Fix NPE when operating on multiple conversations in batch mode. 2021-08-09 11:54:40 -04:00
Cody Henthorne
182c758d35 Revert "Fix NPE when operating on multiple conversations in batch mode."
This reverts commit fc51c4940c.
2021-08-09 11:52:38 -04:00
Greyson Parrelli
f6b2d3faf8 Small refactor to building the sender key target list. 2021-08-06 17:49:08 -04:00
Greyson Parrelli
61c7959ffc Ensure typing indicators are sent as online messages with sender key. 2021-08-06 17:29:25 -04:00
Greyson Parrelli
67ccd14af2 Ensure certain sender key payloads are serialized properly. 2021-08-06 17:29:02 -04:00
Cody Henthorne
3c41b7322f Bump version to 5.20.2 2021-08-06 16:43:18 -04:00
Cody Henthorne
d2118d0b53 Updated language translations. 2021-08-06 16:40:45 -04:00
Alex Hart
89af85c893 Fix MP4 Gif crash with ConversationUpdateItem
Due to adapter positions changing due to deletes and other such
nonsense, there are cases where update items are all of a sudden in our
playing or notplaying arrays. Checking for playable content before
trying to update positioning information seems to have fixed the issue.
2021-08-06 16:41:44 -03:00
Greyson Parrelli
0762a93787 Refactor protobuf validation exceptions. 2021-08-06 14:47:43 -04:00
Cody Henthorne
570b4d7150 Fix bug with processing and displaying long messages with mentions. 2021-08-06 13:19:44 -04:00
Cody Henthorne
fc51c4940c Fix NPE when operating on multiple conversations in batch mode. 2021-08-06 11:14:33 -04:00
Alex Hart
b9ffbb8e92 Fix issue where custom notifications were never enabled.
Older API levels do not have notification channel support, and
we were not checking this state to see if we should enable
the controls. Fix is to add a new controlsEnabled flag on the
state object and set it whenever we finish loading or when recp
changes.
2021-08-06 11:40:09 -03:00
Alex Hart
de2c7d38bf Fix NPE in proximity sensor management.
If a device either does not have a proximity sensor or has a
non-functioning sensor, we can hit an NPE as soon as we hit
MainActivity. This fix ensures proper handling if a sensor is
unavailable.
2021-08-06 11:32:03 -03:00
Alex Hart
c9597ef8dc Fix several small bugs with foldable calling.
* Set proper aspect ratio of pip in landscape mode.
* Fix some fade and adjustment from new UI states.
2021-08-06 11:27:27 -03:00
Greyson Parrelli
a9bbee3880 Trim logs submitted via help and shake2report. 2021-08-05 18:23:20 -04:00
Greyson Parrelli
2bac1a7707 Fix race condition that could show an empty link preview after send. 2021-08-05 17:45:14 -04:00
Cody Henthorne
80e1b2c843 Bump version to 5.20.1 2021-08-05 16:08:17 -04:00
Cody Henthorne
7c2da69676 Updated language translations. 2021-08-05 16:02:58 -04:00
Alex Hart
f25e8d602b Rewrite ContactSelectionListItem to utilize ConstraintLayout. 2021-08-05 16:42:32 -03:00
Greyson Parrelli
b9c6c6b0f4 Include additional logging to assist in debugging. 2021-08-05 16:42:32 -03:00
Alex Hart
164f39e376 Fix issue where group count flashes in contact selection item. 2021-08-05 16:42:32 -03:00
Greyson Parrelli
49190125ef Locally track conversation open time. 2021-08-05 16:42:32 -03:00
Fumiaki Yoshimatsu
555e65d3ee Try a little harder to find a place to store the file before accepting a directory path that may not exist.
Fixes #11505
2021-08-05 16:42:32 -03:00
Greyson Parrelli
89b1243885 Add the "My Daily Life" sticker pack by Plastic Thing. 2021-08-05 16:42:32 -03:00
Cody Henthorne
3fca46de92 Alleviate database contention when archiving threads. 2021-08-05 16:42:32 -03:00
Alex Hart
aa0d7c218f Stage secure outgoing message instead of unwrapped. 2021-08-05 16:42:32 -03:00
Greyson Parrelli
2b5b664a8f Update sender key flag. 2021-08-05 16:42:32 -03:00
Greyson Parrelli
784c373a0e Locally track message send time. 2021-08-05 16:42:32 -03:00
Alex Hart
37ae740138 Hide reveal dot if sender and recvr are both self. 2021-08-05 16:42:32 -03:00
Alex Hart
fe9b8a9f47 Replace with new custom notifications page. 2021-08-05 16:42:32 -03:00
Alex Hart
3585667fb9 Bump version to 5.20.0 2021-08-04 13:39:31 -03:00
Alex Hart
b4ed088529 Updated language translations. 2021-08-04 13:38:59 -03:00
Greyson Parrelli
bdcf390e6e Verify a member is still in the group before using sender key. 2021-08-04 10:55:44 -04:00
Greyson Parrelli
c7551881b8 Update handling of unrestricted UD access. 2021-08-04 10:36:49 -04:00
Greyson Parrelli
c131754874 Add a system for locally tracking performance on-device. 2021-08-04 10:01:14 -04:00
Alex Hart
c6c4988583 Apply proper rules for foldable aspect scaling in landscape and tabletop modes. 2021-08-04 10:57:21 -03:00
Alex Hart
d43f044eb4 Add logic to only dismiss header views when in tabletop mode. 2021-08-04 10:53:53 -03:00
Fumiaki Yoshimatsu
9c71994804 Align text to "start" to show LTR text in RTL view correctly.
Fixes #11335
2021-08-04 10:29:03 -03:00
Sgn-32
654d98b0fe Use getSimpleRelativeTimeSpanString in getCallDateString 2021-08-04 10:01:15 -03:00
Fumiaki Yoshimatsu
c06056d847 Force LTR layout in this view because the "playback" button should not be mirrored in RTL.
In this specific case, the drawable (triangle_right) used in this view
is _not_ autoMirrored which is correct. But the `layout_marginStart`
attribute adds the margin to the wrong side of the view that breaks the
appearance.

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

View File

@@ -24,8 +24,8 @@ jobs:
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Remove Android S
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-S"
- name: Remove Android 31 (S)
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
- name: Build with Gradle
run: ./gradlew qa

View File

@@ -57,8 +57,8 @@ protobuf {
}
}
def canonicalVersionCode = 879
def canonicalVersionName = "5.17.1"
def canonicalVersionCode = 898
def canonicalVersionName = "5.21.1"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -268,7 +268,7 @@ android {
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
}
study {
@@ -368,7 +368,7 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.fragment:fragment-ktx:1.2.5'
implementation 'androidx.fragment:fragment-ktx:1.3.5'
lintChecks project(':lintchecks')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
@@ -376,6 +376,7 @@ dependencies {
implementation ('androidx.appcompat:appcompat:1.2.0') {
force = true
}
implementation "androidx.window:window:1.0.0-alpha09"
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
@@ -391,6 +392,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0'
implementation 'androidx.lifecycle:lifecycle-reactivestreams-ktx:2.1.0'
implementation "androidx.camera:camera-core:1.0.0-beta11"
implementation "androidx.camera:camera-camera2:1.0.0-beta11"
implementation "androidx.camera:camera-lifecycle:1.0.0-beta11"
@@ -432,7 +434,7 @@ dependencies {
implementation 'org.signal:argon2:13.1@aar'
implementation 'org.signal:ringrtc-android:2.10.6'
implementation 'org.signal:ringrtc-android:2.10.8'
implementation "me.leolin:ShortcutBadger:1.1.22"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'

View File

@@ -9,3 +9,5 @@
# Protobuf lite
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-keep class androidx.window.** { *; }

View File

@@ -50,11 +50,13 @@ public class FlipperSqlCipherAdapter extends DatabaseDriver<FlipperSqlCipherAdap
SignalDatabase keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext());
SignalDatabase megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext());
SignalDatabase jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext());
SignalDatabase metricsOpenHelper = LocalMetricsDatabase.getInstance((Application) getContext());
return Arrays.asList(new Descriptor(mainOpenHelper),
new Descriptor(keyValueOpenHelper),
new Descriptor(megaphoneOpenHelper),
new Descriptor(jobManagerOpenHelper));
new Descriptor(jobManagerOpenHelper),
new Descriptor(metricsOpenHelper));
} catch (Exception e) {
Log.i(TAG, "Unable to use reflection to access raw database.", e);
}

View File

@@ -96,6 +96,7 @@
android:label="@string/app_name"
android:supportsRtl="true"
tools:replace="android:allowBackup"
android:resizeableActivity="true"
android:allowBackup="false"
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
@@ -104,6 +105,9 @@
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
<meta-data android:name="android.supports_size_changes"
android:value="true" />
<meta-data android:name="com.google.android.gms.version"
android:value="@integer/google_play_services_version" />
@@ -117,16 +121,15 @@
<activity android:name=".WebRtcCallActivity"
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
android:excludeFromRecents="true"
android:screenOrientation="portrait"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:taskAffinity=".calling"
android:resizeableActivity="true"
android:launchMode="singleTask"/>
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
android:theme="@style/TextSecure.DarkNoActionBar"
android:screenOrientation="portrait"
android:noHistory="true"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
@@ -272,6 +275,16 @@
<data android:scheme="sgnl"
android:host="signal.tube" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="signal.me" />
<data android:scheme="sgnl"
android:host="signal.me" />
</intent-filter>
</activity>
<activity android:name=".conversation.ConversationActivity"
@@ -478,6 +491,7 @@
<activity android:name="org.thoughtcrime.securesms.webrtc.VoiceCallShare"
android:excludeFromRecents="true"
android:permission="android.permission.CALL_PHONE"
android:theme="@style/NoAnimation.Theme.BlackScreen"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">

Binary file not shown.

View File

@@ -8,15 +8,16 @@ public final class AppCapabilities {
private AppCapabilities() {
}
private static final boolean UUID_CAPABLE = false;
private static final boolean GV2_CAPABLE = true;
private static final boolean GV1_MIGRATION = true;
private static final boolean UUID_CAPABLE = false;
private static final boolean GV2_CAPABLE = true;
private static final boolean GV1_MIGRATION = true;
private static final boolean ANNOUNCEMENT_GROUPS = true;
/**
* @param storageCapable Whether or not the user can use storage service. This is another way of
* asking if the user has set a Signal PIN or not.
*/
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, FeatureFlags.senderKey());
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, FeatureFlags.senderKey(), ANNOUNCEMENT_GROUPS);
}
}

View File

@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.Build;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -27,18 +28,17 @@ import androidx.multidex.MultiDexApplication;
import com.google.android.gms.security.ProviderInstaller;
import net.sqlcipher.database.SQLiteDatabase;
import org.conscrypt.Conscrypt;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.PersistentLogger;
import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -56,7 +56,7 @@ import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.LogSecretProvider;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
@@ -74,9 +74,9 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ByteUnit;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
@@ -114,6 +114,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
public void onCreate() {
Tracer.getInstance().start("Application#onCreate()");
AppStartup.getInstance().onApplicationCreate();
SignalLocalMetrics.ColdStart.start();
long startTime = System.currentTimeMillis();
@@ -124,12 +125,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
super.onCreate();
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
.addBlocking("logging", () -> {
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
.addBlocking("rx-init", () -> {
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
@@ -156,6 +157,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
})
.addBlocking("blob-provider", this::initializeBlobProvider)
.addBlocking("feature-flags", FeatureFlags::init)
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
.addNonBlocking(this::initializeGcmCheck)
@@ -178,6 +180,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
SignalLocalMetrics.ColdStart.onApplicationCreateFinished();
Tracer.getInstance().end("Application#onCreate()");
}
@@ -248,10 +251,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void initializeLogging() {
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
persistentLogger = new PersistentLogger(this);
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
SignalExecutors.UNBOUNDED.execute(() -> LogDatabase.getInstance(this).trimToSize());
}
private void initializeCrashHandling() {
@@ -379,6 +384,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
BlobProvider.getInstance().initialize(this);
}
@WorkerThread
private void cleanAvatarStorage() {
AvatarPickerStorage.cleanOrphans(this);
}
@WorkerThread
private void initializeCleanup() {
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms;
import android.graphics.Point;
import android.net.Uri;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
@@ -10,9 +12,12 @@ import androidx.lifecycle.Observer;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
@@ -31,14 +36,14 @@ import java.util.List;
import java.util.Locale;
import java.util.Set;
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable {
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable, Multiselectable {
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ConversationMessage messageRecord,
@NonNull Optional<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Set<MultiselectPart> batchSelected,
@NonNull Recipient recipients,
@Nullable String searchQuery,
boolean pulseMention,

View File

@@ -36,6 +36,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.function.Consumer;
/**
* Base activity container for selecting a list of contacts.
@@ -122,8 +123,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
return true;
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
callback.accept(true);
}
@Override

View File

@@ -26,6 +26,7 @@ import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -37,6 +38,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
@@ -53,6 +55,7 @@ import androidx.transition.TransitionManager;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.logging.Log;
@@ -88,6 +91,7 @@ import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
/**
* Fragment for selecting a one or more contacts from a list.
@@ -176,12 +180,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
}
if (getParentFragment() instanceof OnSelectionLimitReachedListener) {
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
}
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
}
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
}
}
@@ -260,7 +268,9 @@ public final class ContactSelectionListFragment extends LoggingFragment
recyclerView.setClipToPadding(recyclerViewClipping);
swipeRefresh.setEnabled(arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true)));
boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true));
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
swipeRefresh.setEnabled(isRefreshable);
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
@@ -436,6 +446,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
FragmentActivity activity = requireActivity();
@@ -566,28 +580,32 @@ public final class ContactSelectionListFragment extends LoggingFragment
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
if (onContactSelectedListener != null) {
if (onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null)) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
if (allowed) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.show();
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.show();
}
});
} else {
if (onContactSelectedListener != null) {
if (onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber())) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
if (allowed) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
@@ -742,8 +760,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public interface OnContactSelectedListener {
/** @return True if the contact is allowed to be selected, otherwise false. */
boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number);
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback);
void onContactDeselected(Optional<RecipientId> recipientId, String number);
void onSelectionChanged();
}

View File

@@ -150,6 +150,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
});
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);

View File

@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.util.text.AfterTextChanged;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.concurrent.ExecutionException;
import java.util.function.Consumer;
public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener {
@@ -139,9 +140,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
return true;
callback.accept(true);
}
@Override

View File

@@ -49,6 +49,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
handleGroupLinkInIntent(getIntent());
handleProxyInIntent(getIntent());
handleSignalMeIntent(getIntent());
CachedInflater.from(this).clear();
}
@@ -65,6 +66,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
super.onNewIntent(intent);
handleGroupLinkInIntent(intent);
handleProxyInIntent(intent);
handleSignalMeIntent(intent);
}
@Override
@@ -115,6 +117,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
private void handleSignalMeIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString());
}
}
@Override
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
return mediaController;

View File

@@ -171,6 +171,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
initializeObservers();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);

View File

@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.function.Consumer;
/**
* Activity container for starting a new conversation.
@@ -60,7 +61,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {
@@ -94,7 +95,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
}
return true;
callback.accept(true);
}
@Override

View File

@@ -208,6 +208,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
.execute();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);

View File

@@ -18,11 +18,14 @@
package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.media.AudioManager;
import android.os.Build;
import android.os.Bundle;
@@ -34,11 +37,16 @@ import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.ViewModelProviders;
import androidx.window.DisplayFeature;
import androidx.window.FoldingFeature;
import androidx.window.WindowLayoutInfo;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
@@ -49,6 +57,7 @@ import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeN
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -62,13 +71,16 @@ import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
@@ -87,11 +99,14 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
private DeviceOrientationMonitor deviceOrientationMonitor;
private FullscreenHelper fullscreenHelper;
private WebRtcCallView callScreen;
private TooltipPopup videoTooltip;
private WebRtcCallViewModel viewModel;
private boolean enableVideoIfAvailable;
private FullscreenHelper fullscreenHelper;
private WebRtcCallView callScreen;
private TooltipPopup videoTooltip;
private WebRtcCallViewModel viewModel;
private boolean enableVideoIfAvailable;
private androidx.window.WindowManager windowManager;
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
private ThrottledDebouncer requestNewSizesThrottle;
@Override
protected void attachBaseContext(@NonNull Context newBase) {
@@ -99,6 +114,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
super.attachBaseContext(newBase);
}
@SuppressLint("SourceLockedOrientationActivity")
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate()");
@@ -106,6 +122,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
super.onCreate(savedInstanceState);
boolean isLandscapeEnabled = getResources().getConfiguration().smallestScreenWidthDp >= 480;
if (!isLandscapeEnabled) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.webrtc_call_activity);
@@ -114,12 +135,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
initializeResources();
initializeViewModel();
initializeViewModel(isLandscapeEnabled);
processIntent(getIntent());
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
windowManager = new androidx.window.WindowManager(this);
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
windowManager.registerLayoutChangeCallback(SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
}
@Override
@@ -164,6 +192,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
if (!isInPipMode() || isFinishing()) {
EventBus.getDefault().unregister(this);
requestNewSizesThrottle.clear();
}
if (!viewModel.isCallStarting()) {
@@ -177,9 +206,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
protected void onDestroy() {
super.onDestroy();
windowManager.unregisterLayoutChangeCallback(windowLayoutInfoConsumer);
EventBus.getDefault().unregister(this);
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -206,8 +237,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private boolean enterPipModeIfPossible() {
if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
PictureInPictureParams params = new PictureInPictureParams.Builder()
.setAspectRatio(new Rational(9, 16))
.build();
.setAspectRatio(new Rational(9, 16))
.build();
enterPictureInPictureMode(params);
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
@@ -246,20 +277,23 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
}
private void initializeViewModel() {
private void initializeViewModel(boolean isLandscapeEnabled) {
deviceOrientationMonitor = new DeviceOrientationMonitor(this);
getLifecycle().addObserver(deviceOrientationMonitor);
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
viewModel.setIsInPipMode(isInPipMode());
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientation(), (s, o) -> new Pair<>(s, o == PORTRAIT_BOTTOM_EDGE))
.observe(this, p -> callScreen.updateCallParticipants(p.first(), p.second()));
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
viewModel.getOrientationAndLandscapeEnabled(),
(s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
.observe(this, p -> callScreen.updateCallParticipants(p));
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
@@ -269,30 +303,18 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
if (state != null) {
if (state.needsNewRequestSizes()) {
ApplicationDependencies.getSignalCallManager().updateRenderedResolutions();
requestNewSizesThrottle.publish(() -> ApplicationDependencies.getSignalCallManager().updateRenderedResolutions());
}
}
});
viewModel.getOrientation().observe(this, orientation -> {
ApplicationDependencies.getSignalCallManager().orientationChanged(orientation.getDegrees());
switch (orientation) {
case LANDSCAPE_LEFT_EDGE:
callScreen.rotateControls(90);
break;
case LANDSCAPE_RIGHT_EDGE:
callScreen.rotateControls(-90);
break;
case PORTRAIT_BOTTOM_EDGE:
callScreen.rotateControls(0);
}
});
viewModel.getOrientationAndLandscapeEnabled().observe(this, pair -> ApplicationDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
viewModel.getControlsRotation().observe(this, callScreen::rotateControls);
}
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
if (event instanceof WebRtcCallViewModel.Event.StartCall) {
startCall(((WebRtcCallViewModel.Event.StartCall)event).isVideoCall());
startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall());
return;
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
@@ -404,7 +426,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
.onAllGranted(() -> {
callScreen.setRecipient(recipient);
@@ -488,13 +510,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
new AlertDialog.Builder(this)
.setTitle(R.string.RedPhone_number_not_registered)
.setIcon(R.drawable.ic_warning)
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
.setCancelable(true)
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.show();
.setTitle(R.string.RedPhone_number_not_registered)
.setIcon(R.drawable.ic_warning)
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
.setCancelable(true)
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
.show();
}
private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) {
@@ -586,20 +608,34 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
callScreen.setRecipient(event.getRecipient());
switch (event.getState()) {
case CALL_PRE_JOIN: handleCallPreJoin(event); break;
case CALL_CONNECTED: handleCallConnected(event); break;
case NETWORK_FAILURE: handleServerFailure(); break;
case CALL_RINGING: handleCallRinging(); break;
case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
case NO_SUCH_USER: handleNoSuchUser(event); break;
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(); break;
case CALL_OUTGOING: handleOutgoingCall(event); break;
case CALL_BUSY: handleCallBusy(); break;
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
case CALL_PRE_JOIN:
handleCallPreJoin(event); break;
case CALL_CONNECTED:
handleCallConnected(event); break;
case NETWORK_FAILURE:
handleServerFailure(); break;
case CALL_RINGING:
handleCallRinging(); break;
case CALL_DISCONNECTED:
handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
case CALL_ACCEPTED_ELSEWHERE:
handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
case CALL_DECLINED_ELSEWHERE:
handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
case CALL_ONGOING_ELSEWHERE:
handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
case CALL_NEEDS_PERMISSION:
handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
case NO_SUCH_USER:
handleNoSuchUser(event); break;
case RECIPIENT_UNAVAILABLE:
handleRecipientUnavailable(); break;
case CALL_OUTGOING:
handleOutgoingCall(event); break;
case CALL_BUSY:
handleCallBusy(); break;
case UNTRUSTED_IDENTITY:
handleUntrustedIdentity(event); break;
}
boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
@@ -730,4 +766,27 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.onLocalPictureInPictureClicked();
}
}
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
@Override
public void accept(WindowLayoutInfo windowLayoutInfo) {
Log.d(TAG, "On WindowLayoutInfo accepted: " + windowLayoutInfo.toString());
Optional<DisplayFeature> feature = windowLayoutInfo.getDisplayFeatures().stream().filter(f -> f instanceof FoldingFeature).findFirst();
viewModel.setIsLandscapeEnabled(feature.isPresent());
setRequestedOrientation(feature.isPresent() ? ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
if (feature.isPresent()) {
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
Rect bounds = foldingFeature.getBounds();
if (foldingFeature.getState() == FoldingFeature.State.HALF_OPENED && bounds.top == bounds.bottom) {
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
} else {
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in flat display mode");
viewModel.setFoldableState(WebRtcControls.FoldableState.flat());
}
}
}
}
}

View File

@@ -32,6 +32,7 @@ public class AudioCodec {
private final AudioRecord audioRecord;
private boolean running = true;
private boolean failed = false;
private boolean finished = false;
public AudioCodec() throws IOException {
@@ -76,10 +77,25 @@ public class AudioCodec {
} catch (IOException e) {
Log.w(TAG, e);
} finally {
mediaCodec.stop();
audioRecord.stop();
mediaCodec.release();
try {
mediaCodec.stop();
} catch (IllegalStateException ise) {
Log.w(TAG, "mediaCodec stop failed.", ise);
}
try {
audioRecord.stop();
} catch (IllegalStateException ise) {
Log.w(TAG, "audioRecord stop failed.", ise);
}
try {
mediaCodec.release();
} catch (IllegalStateException ise) {
Log.w(TAG, "mediaCodec release failed. Probably already released.", ise);
}
audioRecord.release();
StreamUtil.close(outputStream);

View File

@@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.avatar
import android.net.Uri
import org.thoughtcrime.securesms.R
/**
* Represents an Avatar which the user can choose, edit, and render into a bitmap via the renderer.
*/
sealed class Avatar(
open val databaseId: DatabaseId
) {
data class Resource(
val resourceId: Int,
val color: Avatars.ColorPair
) : Avatar(DatabaseId.DoNotPersist) {
override fun isSameAs(other: Avatar): Boolean {
return other is Resource && other.resourceId == resourceId
}
}
data class Text(
val text: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Text && other.databaseId == databaseId
}
}
data class Vector(
val key: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Vector && other.key == key
}
}
data class Photo(
val uri: Uri,
val size: Long,
override val databaseId: DatabaseId
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Photo && databaseId == other.databaseId
}
}
open fun withDatabaseId(databaseId: DatabaseId): Avatar {
throw UnsupportedOperationException()
}
abstract fun isSameAs(other: Avatar): Boolean
companion object {
fun getDefaultForSelf(): Resource = Resource(R.drawable.ic_profile_outline_40, Avatars.colors.random())
fun getDefaultForGroup(): Resource = Resource(R.drawable.ic_group_outline_40, Avatars.colors.random())
}
sealed class DatabaseId {
object DoNotPersist : DatabaseId()
object NotSet : DatabaseId()
data class Saved(val id: Long) : DatabaseId()
}
}

View File

@@ -0,0 +1,69 @@
package org.thoughtcrime.securesms.avatar
import android.os.Bundle
import java.lang.IllegalStateException
/**
* Utility class which encapsulates reading and writing Avatar objects to and from Bundles.
*/
object AvatarBundler {
private const val TEXT = "org.thoughtcrime.securesms.avatar.TEXT"
private const val COLOR = "org.thoughtcrime.securesms.avatar.COLOR"
private const val URI = "org.thoughtcrime.securesms.avatar.URI"
private const val KEY = "org.thoughtcrime.securesms.avatar.KEY"
private const val DATABASE_ID = "org.thoughtcrime.securesms.avatar.DATABASE_ID"
private const val SIZE = "org.thoughtcrime.securesms.avatar.SIZE"
fun bundleText(text: Avatar.Text): Bundle = Bundle().apply {
putString(TEXT, text.text)
putString(COLOR, text.color.code)
putDatabaseId(DATABASE_ID, text.databaseId)
}
fun extractText(bundle: Bundle): Avatar.Text = Avatar.Text(
text = requireNotNull(bundle.getString(TEXT)),
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
databaseId = bundle.getDatabaseId()
)
fun bundlePhoto(photo: Avatar.Photo): Bundle = Bundle().apply {
putParcelable(URI, photo.uri)
putLong(SIZE, photo.size)
putDatabaseId(DATABASE_ID, photo.databaseId)
}
fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo(
uri = requireNotNull(bundle.getParcelable(URI)),
size = bundle.getLong(SIZE),
databaseId = bundle.getDatabaseId()
)
fun bundleVector(vector: Avatar.Vector): Bundle = Bundle().apply {
putString(KEY, vector.key)
putString(COLOR, vector.color.code)
putDatabaseId(DATABASE_ID, vector.databaseId)
}
fun extractVector(bundle: Bundle): Avatar.Vector = Avatar.Vector(
key = requireNotNull(bundle.getString(KEY)),
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
databaseId = bundle.getDatabaseId()
)
private fun Bundle.getDatabaseId(): Avatar.DatabaseId {
val id = getLong(DATABASE_ID, -1L)
return if (id == -1L) {
Avatar.DatabaseId.NotSet
} else {
Avatar.DatabaseId.Saved(id)
}
}
private fun Bundle.putDatabaseId(key: String, databaseId: Avatar.DatabaseId) {
if (databaseId is Avatar.DatabaseId.Saved) {
putLong(key, databaseId.id)
}
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.avatar
import android.view.View
import android.widget.ImageView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit
/**
* Selectable color item for choosing colors when editing a Text or Vector avatar.
*/
data class AvatarColorItem(
val colors: Avatars.ColorPair,
val selected: Boolean
) {
companion object {
fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
}
}
class Model(val colorItem: AvatarColorItem) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = newItem.colorItem.colors == colorItem.colors
override fun areContentsTheSame(newItem: Model): Boolean = newItem.colorItem == colorItem
}
private class ViewHolder(itemView: View, private val onAvatarColorClickListener: OnAvatarColorClickListener) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = findViewById(R.id.avatar_color_item)
override fun bind(model: Model) {
itemView.setOnClickListener { onAvatarColorClickListener(model.colorItem.colors) }
imageView.background.colorFilter = SimpleColorFilter(model.colorItem.colors.backgroundColor)
imageView.isSelected = model.colorItem.selected
}
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.storage.FileStorage
import java.io.InputStream
object AvatarPickerStorage {
private const val DIRECTORY = "avatar_picker"
private const val FILENAME_BASE = "avatar"
@JvmStatic
fun read(context: Context, fileName: String) = FileStorage.read(context, DIRECTORY, fileName)
fun save(context: Context, media: Media): Uri {
val fileName = FileStorage.save(context, PartAuthority.getAttachmentStream(context, media.uri), DIRECTORY, FILENAME_BASE, MediaUtil.getExtension(context, media.uri) ?: "")
return PartAuthority.getAvatarPickerUri(fileName)
}
fun save(context: Context, inputStream: InputStream): Uri {
val fileName = FileStorage.save(context, inputStream, DIRECTORY, FILENAME_BASE, MimeTypeMap.getSingleton().getExtensionFromMimeType(MediaUtil.IMAGE_JPEG) ?: "")
return PartAuthority.getAvatarPickerUri(fileName)
}
@JvmStatic
fun cleanOrphans(context: Context) {
val avatarFiles = FileStorage.getAllFiles(context, DIRECTORY, FILENAME_BASE)
val database = DatabaseFactory.getAvatarPickerDatabase(context)
val photoAvatars = database
.getAllAvatars()
.filterIsInstance<Avatar.Photo>()
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.map { it.name }
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
avatarFiles
.filter { onDiskButNotInDatabase.contains(it.name) }
.forEach { it.delete() }
photoAvatars
.filter { inDatabaseButNotOnDisk.contains(PartAuthority.getAvatarPickerFilename(it.uri)) }
.forEach { database.deleteAvatar(it) }
}
}

View File

@@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.libsignal.util.guava.Optional
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import javax.annotation.meta.Exhaustive
/**
* Renders Avatar objects into Media objects. This can involve creating a Bitmap, depending on the
* type of Avatar passed to `renderAvatar`
*/
object AvatarRenderer {
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
fun getTypeface(context: Context): Typeface {
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
}
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
@Exhaustive
when (avatar) {
is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed)
is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed)
is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered)
is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed)
}
}
@JvmStatic
fun createTextDrawable(
context: Context,
avatar: Avatar.Text,
inverted: Boolean = false,
size: Int = DIMENSIONS,
): Drawable {
return TextAvatarDrawable(context, avatar, inverted, size)
}
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val drawableResourceId = Avatars.getDrawableResource(avatar.key) ?: return@renderInBackground Result.failure(Exception("Drawable resource for key ${avatar.key} does not exist."))
val vector: Drawable = requireNotNull(AppCompatResources.getDrawable(context, drawableResourceId))
vector.setBounds(0, 0, DIMENSIONS, DIMENSIONS)
canvas.drawColor(avatar.color.backgroundColor)
vector.draw(canvas)
Result.success(Unit)
}
}
private fun renderText(context: Context, avatar: Avatar.Text, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val textDrawable = createTextDrawable(context, avatar)
canvas.drawColor(avatar.color.backgroundColor)
textDrawable.draw(canvas)
Result.success(Unit)
}
}
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
val blob = BlobProvider.getInstance()
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
.createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(blob, avatar.size))
}
}
private fun renderResource(context: Context, avatar: Avatar.Resource, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val resource: Drawable = requireNotNull(AppCompatResources.getDrawable(context, avatar.resourceId))
resource.colorFilter = SimpleColorFilter(avatar.color.foregroundColor)
val padding = (DIMENSIONS * 0.2).toInt()
resource.setBounds(0 + padding, 0 + padding, DIMENSIONS - padding, DIMENSIONS - padding)
canvas.drawColor(avatar.color.backgroundColor)
resource.draw(canvas)
Result.success(Unit)
}
}
private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result<Unit>) {
SignalExecutors.BOUNDED.execute {
val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888)
val canvas = Canvas(canvasBitmap)
val drawResult = drawAvatar(canvas)
if (drawResult.isFailure) {
canvasBitmap.recycle()
onRenderFailed(drawResult.exceptionOrNull())
}
val outStream = ByteArrayOutputStream()
val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream)
canvasBitmap.recycle()
if (!compressed) {
onRenderFailed(IOException("Failed to compress bitmap"))
return@execute
}
val bytes = outStream.toByteArray()
val inStream = ByteArrayInputStream(bytes)
val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
}
}
private fun createMedia(uri: Uri, size: Long): Media {
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent())
}
}

View File

@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Paint
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import kotlin.math.abs
import kotlin.math.min
object Avatars {
/**
* Enum class mirroring AvatarColors codes but utilizing foreground colors for text or icon tinting.
*/
enum class ForegroundColor(private val code: String, @ColorInt val colorInt: Int) {
A100("A100", 0xFF3838F5.toInt()),
A110("A110", 0xFF1251D3.toInt()),
A120("A120", 0xFF086DA0.toInt()),
A130("A130", 0xFF067906.toInt()),
A140("A140", 0xFF661AFF.toInt()),
A150("A150", 0xFF9F00F0.toInt()),
A160("A160", 0xFFB8057C.toInt()),
A170("A170", 0xFFBE0404.toInt()),
A180("A180", 0xFF836B01.toInt()),
A190("A190", 0xFF7D6F40.toInt()),
A200("A200", 0xFF4F4F6D.toInt()),
A210("A210", 0xFF5C5C5C.toInt());
fun deserialize(code: String): ForegroundColor {
return values().find { it.code == code } ?: throw IllegalArgumentException()
}
fun serialize(): String = code
}
/**
* Mapping which associates color codes to ColorPair objects containing background and foreground colors.
*/
val colorMap: Map<String, ColorPair> = ForegroundColor.values().map {
ColorPair(AvatarColor.deserialize(it.serialize()), it)
}.associateBy {
it.code
}
val colors: List<ColorPair> = colorMap.values.toList()
val defaultAvatarsForSelf = linkedMapOf(
"avatar_abstract_01" to DefaultAvatar(R.drawable.ic_avatar_abstract_01, "A130"),
"avatar_abstract_02" to DefaultAvatar(R.drawable.ic_avatar_abstract_02, "A120"),
"avatar_abstract_03" to DefaultAvatar(R.drawable.ic_avatar_abstract_03, "A170"),
"avatar_cat" to DefaultAvatar(R.drawable.ic_avatar_cat, "A190"),
"avatar_dog" to DefaultAvatar(R.drawable.ic_avatar_dog, "A140"),
"avatar_fox" to DefaultAvatar(R.drawable.ic_avatar_fox, "A190"),
"avatar_tucan" to DefaultAvatar(R.drawable.ic_avatar_tucan, "A120"),
"avatar_sloth" to DefaultAvatar(R.drawable.ic_avatar_sloth, "A160"),
"avatar_dinosaur" to DefaultAvatar(R.drawable.ic_avatar_dinosour, "A130"),
"avatar_pig" to DefaultAvatar(R.drawable.ic_avatar_pig, "A180"),
"avatar_incognito" to DefaultAvatar(R.drawable.ic_avatar_incognito, "A220"),
"avatar_ghost" to DefaultAvatar(R.drawable.ic_avatar_ghost, "A100")
)
val defaultAvatarsForGroup = linkedMapOf(
"avatar_heart" to DefaultAvatar(R.drawable.ic_avatar_heart, "A180"),
"avatar_house" to DefaultAvatar(R.drawable.ic_avatar_house, "A120"),
"avatar_melon" to DefaultAvatar(R.drawable.ic_avatar_melon, "A110"),
"avatar_drink" to DefaultAvatar(R.drawable.ic_avatar_drink, "A170"),
"avatar_celebration" to DefaultAvatar(R.drawable.ic_avatar_celebration, "A100"),
"avatar_balloon" to DefaultAvatar(R.drawable.ic_avatar_balloon, "A220"),
"avatar_book" to DefaultAvatar(R.drawable.ic_avatar_book, "A100"),
"avatar_briefcase" to DefaultAvatar(R.drawable.ic_avatar_briefcase, "A180"),
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
)
@DrawableRes
fun getDrawableResource(key: String): Int? {
val defaultAvatar = defaultAvatarsForSelf.getOrDefault(key, defaultAvatarsForGroup[key])
return defaultAvatar?.vectorDrawableId
}
private fun textPaint(context: Context) = Paint().apply {
isAntiAlias = true
typeface = AvatarRenderer.getTypeface(context)
textSize = 1f
}
/**
* Calculate the text size for a give string using a maximum desired width and a maximum desired font size.
*/
@JvmStatic
fun getTextSizeForLength(context: Context, text: String, @Px maxWidth: Float, @Px maxSize: Float): Float {
val paint = textPaint(context)
return branchSizes(0f, maxWidth / 2, maxWidth, maxSize, paint, text)
}
/**
* Uses binary search to determine optimal font size to within 1% given the input parameters.
*/
private fun branchSizes(@Px lastFontSize: Float, @Px fontSize: Float, @Px target: Float, @Px maxFontSize: Float, paint: Paint, text: String): Float {
paint.textSize = fontSize
val textWidth = paint.measureText(text)
val delta = abs(lastFontSize - fontSize) / 2f
val isWithinThreshold = abs(1f - (textWidth / target)) <= 0.01f
if (textWidth == 0f) {
return maxFontSize
}
if (delta == 0f) {
return min(maxFontSize, fontSize)
}
return when {
fontSize >= maxFontSize -> {
maxFontSize
}
isWithinThreshold -> {
fontSize
}
textWidth > target -> {
branchSizes(fontSize, fontSize - delta, target, maxFontSize, paint, text)
}
else -> {
branchSizes(fontSize, fontSize + delta, target, maxFontSize, paint, text)
}
}
}
@JvmStatic
fun getForegroundColor(avatarColor: AvatarColor): ForegroundColor {
return ForegroundColor.values().firstOrNull { it.serialize() == avatarColor.serialize() } ?: ForegroundColor.A210
}
data class DefaultAvatar(
@DrawableRes val vectorDrawableId: Int,
val colorCode: String
)
data class ColorPair(
val backgroundAvatarColor: AvatarColor,
val foregroundAvatarColor: ForegroundColor
) {
@ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
@ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
val code: String = backgroundAvatarColor.serialize()
}
}

View File

@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.Gravity
import android.widget.FrameLayout
import androidx.core.view.updateLayoutParams
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
/**
* Uses EmojiTextView to properly render a Text Avatar with emoji in it.
*/
class TextAvatarDrawable(
context: Context,
avatar: Avatar.Text,
inverted: Boolean = false,
private val size: Int = AvatarRenderer.DIMENSIONS,
) : Drawable() {
private val layout: FrameLayout = FrameLayout(context)
private val textView: EmojiTextView = EmojiTextView(context)
init {
textView.typeface = AvatarRenderer.getTypeface(context)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
textView.text = avatar.text
textView.gravity = Gravity.CENTER
textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
textView.setForceCustomEmoji(true)
layout.addView(textView)
textView.updateLayoutParams {
width = size
height = size
}
layout.measure(size, size)
layout.layout(0, 0, size, size)
}
override fun getIntrinsicHeight(): Int = size
override fun getIntrinsicWidth(): Int = size
override fun draw(canvas: Canvas) {
layout.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.OPAQUE
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.avatar.photo
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.setFragmentResult
import androidx.navigation.Navigation
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri)
childFragmentManager.commit {
add(R.id.fragment_container, imageEditorFragment, IMAGE_EDITOR)
}
}
override fun onTouchEventsNeeded(needed: Boolean) {
}
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) {
}
override fun onDoneEditing() {
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
val applicationContext = requireContext().applicationContext
val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment
SignalExecutors.BOUNDED.execute {
val editedImageUri = imageEditorFragment.renderToSingleUseBlob()
val size = BlobProvider.getFileSize(editedImageUri) ?: 0
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream)
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val database = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val newPhoto = photo.copy(uri = onDiskUri, size = size)
database.update(newPhoto)
BlobProvider.getInstance().delete(requireContext(), photo.uri)
ThreadUtil.runOnMain {
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
Navigation.findNavController(requireView()).popBackStack()
}
}
}
companion object {
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"
private const val IMAGE_EDITOR = "image_editor"
}
}

View File

@@ -0,0 +1,243 @@
package org.thoughtcrime.securesms.avatar.picker
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
import org.thoughtcrime.securesms.components.ButtonStripItemView
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
/**
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
*/
class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
companion object {
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
private const val REQUEST_CODE_SELECT_IMAGE = 1
}
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var recycler: RecyclerView
private fun createFactory(): AvatarPickerViewModel.Factory {
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
val groupId = ParcelableGroupId.get(args.groupId)
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar)
val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera)
val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo)
val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text)
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
recycler = view.findViewById(R.id.avatar_picker_recycler)
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
val adapter = MappingAdapter()
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
recycler.adapter = adapter
val avatarViewHolder = AvatarPickerItem.ViewHolder(view)
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.currentAvatar != null) {
avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
}
clearButton.visible = state.canClear
val wasEnabled = saveButton.isEnabled
saveButton.isEnabled = state.canSave
if (wasEnabled != state.canSave) {
val alpha = if (state.canSave) 1f else 0.5f
saveButton.animate().cancel()
saveButton.animate().alpha(alpha)
}
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
val selectedPosition = items.indexOfFirst { it.isSelected }
adapter.submitList(items) {
if (selectedPosition > -1)
recycler.smoothScrollToPosition(selectedPosition)
}
}
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
cameraButton.setOnIconClickedListener { openCameraCapture() }
photoButton.setOnIconClickedListener { openGallery() }
textButton.setOnIconClickedListener { openTextEditor(null) }
saveButton.setOnClickListener { v ->
viewModel.save(
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putParcelable(SELECT_AVATAR_MEDIA, it)
}
)
Navigation.findNavController(v).popBackStack()
},
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putBoolean(SELECT_AVATAR_CLEAR, true)
}
)
Navigation.findNavController(v).popBackStack()
}
)
}
clearButton.setOnClickListener { viewModel.clear() }
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
val text = AvatarBundler.extractText(bundle)
viewModel.onAvatarEditCompleted(text)
}
setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle ->
val vector = AvatarBundler.extractVector(bundle)
viewModel.onAvatarEditCompleted(vector)
}
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
val photo = AvatarBundler.extractPhoto(bundle)
viewModel.onAvatarEditCompleted(photo)
}
}
override fun onResume() {
super.onResume()
ViewUtil.hideKeyboard(requireContext(), requireView())
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
val media: Media = Objects.requireNonNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
viewModel.onAvatarPhotoSelectionCompleted(media)
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) {
if (isSelected) {
openEditor(avatar)
} else {
viewModel.onAvatarSelectedFromGrid(avatar)
}
}
private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean {
val menuRes = when (avatar) {
is Avatar.Photo -> R.menu.avatar_picker_context
is Avatar.Text -> R.menu.avatar_picker_context
is Avatar.Vector -> return true
is Avatar.Resource -> return true
}
val popup = PopupMenu(context, anchorView, Gravity.TOP)
popup.menuInflater.inflate(menuRes, popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_delete -> viewModel.delete(avatar)
}
true
}
popup.show()
return true
}
fun openEditor(avatar: Avatar) {
when (avatar) {
is Avatar.Photo -> openPhotoEditor(avatar)
is Avatar.Resource -> throw UnsupportedOperationException()
is Avatar.Text -> openTextEditor(avatar)
is Avatar.Vector -> openVectorEditor(avatar)
}
}
fun openPhotoEditor(photo: Avatar.Photo) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
}
fun openVectorEditor(vector: Avatar.Vector) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
}
fun openTextEditor(text: Avatar.Text?) {
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
}
fun openCameraCapture() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.onAnyDenied {
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT)
.show()
}
.execute()
}
fun openGallery() {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForGallery(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.onAnyDenied {
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT)
.show()
}
.execute()
}
}

View File

@@ -0,0 +1,147 @@
package org.thoughtcrime.securesms.avatar.picker
import android.util.TypedValue
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.setPadding
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean
object AvatarPickerItem {
private val SELECTION_CHANGED = Any()
fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
}
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar)
override fun areContentsTheSame(newItem: Model): Boolean = avatar == newItem.avatar && isSelected == newItem.isSelected
override fun getChangePayload(newItem: Model): Any? {
return if (newItem.avatar == avatar && isSelected != newItem.isSelected) {
SELECTION_CHANGED
} else {
null
}
}
}
class ViewHolder(
itemView: View,
private val onAvatarClickListener: OnAvatarClickListener? = null,
private val onAvatarLongClickListener: OnAvatarLongClickListener? = null
) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image)
private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text)
private val selectedFader: View? = itemView.findViewById(R.id.avatar_picker_item_fader)
private val selectedOverlay: View? = itemView.findViewById(R.id.avatar_picker_item_selection_overlay)
init {
textView.typeface = AvatarRenderer.getTypeface(context)
textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateFontSize(textView.text.toString())
}
}
private fun updateFontSize(text: String) {
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
if (textView !is EditText) {
textView.text = text
}
}
override fun bind(model: Model) {
val alpha = if (model.isSelected) 1f else 0f
val scale = if (model.isSelected) 0.9f else 1f
imageView.animate().cancel()
textView.animate().cancel()
selectedOverlay?.animate()?.cancel()
selectedFader?.animate()?.cancel()
itemView.setOnLongClickListener {
onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false
}
itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) }
if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) {
imageView.animate().scaleX(scale).scaleY(scale)
textView.animate().scaleX(scale).scaleY(scale)
selectedOverlay?.animate()?.alpha(alpha)
selectedFader?.animate()?.alpha(alpha)
return
}
imageView.scaleX = scale
imageView.scaleY = scale
textView.scaleX = scale
textView.scaleY = scale
selectedFader?.alpha = alpha
selectedOverlay?.alpha = alpha
imageView.clearColorFilter()
imageView.setPadding(0)
when (model.avatar) {
is Avatar.Text -> {
textView.visible = true
updateFontSize(model.avatar.text)
if (textView.text.toString() != model.avatar.text) {
textView.text = model.avatar.text
}
imageView.setImageDrawable(null)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
textView.setTextColor(model.avatar.color.foregroundColor)
}
is Avatar.Vector -> {
textView.visible = false
val drawableId = Avatars.getDrawableResource(model.avatar.key)
if (drawableId == null) {
imageView.setImageDrawable(null)
} else {
imageView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId))
}
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
is Avatar.Photo -> {
textView.visible = false
GlideApp.with(imageView).load(DecryptableStreamUriLoader.DecryptableUri(model.avatar.uri)).into(imageView)
}
is Avatar.Resource -> {
imageView.setPadding((imageView.width * 0.2).toInt())
textView.visible = false
GlideApp.with(imageView).clear(imageView)
imageView.setImageResource(model.avatar.resourceId)
imageView.colorFilter = SimpleColorFilter(model.avatar.color.foregroundColor)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
package org.thoughtcrime.securesms.avatar.picker
import android.content.Context
import android.net.Uri
import android.widget.Toast
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.StreamUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NameUtil
import org.whispersystems.signalservice.api.util.StreamDetails
import java.io.IOException
private val TAG = Log.tag(AvatarPickerRepository::class.java)
class AvatarPickerRepository(context: Context) {
private val applicationContext = context.applicationContext
fun getAvatarForSelf(): Single<Avatar> = Single.fromCallable {
val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext)
if (details != null) {
try {
val bytes = StreamUtil.readFully(details.stream)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
details.length,
Avatar.DatabaseId.DoNotPersist
)
} catch (e: IOException) {
Log.w(TAG, "Failed to read avatar!")
getDefaultAvatarForSelf()
}
} else {
getDefaultAvatarForSelf()
}
}
fun getAvatarForGroup(groupId: GroupId): Single<Avatar> = Single.fromCallable {
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) {
try {
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
Avatar.DatabaseId.DoNotPersist
)
} catch (e: IOException) {
Log.w(TAG, "Failed to read group avatar!")
getDefaultAvatarForGroup(recipient.avatarColor)
}
} else {
getDefaultAvatarForGroup(recipient.avatarColor)
}
}
fun getPersistedAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForSelf()
}
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForGroup(groupId)
}
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
}
}
fun getDefaultAvatarsForGroup(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
}
}
fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) {
SignalExecutors.BOUNDED.execute {
onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media))
}
}
fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
}
}
fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
}
}
fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
persistAvatarForSelf(avatar) {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
} else {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
}
fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
persistAvatarForGroup(avatar, groupId) {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
} else {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
}
fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
fun handleRenderFailure(throwable: Throwable?) {
Log.w(TAG, "Failed to render avatar.", throwable)
ThreadUtil.postToMain {
Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show()
}
}
fun getDefaultAvatarForSelf(): Avatar {
val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext))
return if (initials.isNullOrBlank()) {
Avatar.getDefaultForSelf()
} else {
Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
}
}
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
return getDefaultAvatarForGroup(recipient.avatarColor)
}
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
val colorPair = Avatars.colorMap[color?.serialize()]
val defaultColor = Avatar.getDefaultForGroup()
return if (colorPair != null) {
defaultColor.copy(color = colorPair)
} else {
defaultColor
}
}
fun delete(avatar: Avatar, onDelete: () -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
avatarDatabase.deleteAvatar(avatar)
}
onDelete()
}
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.picker
import org.thoughtcrime.securesms.avatar.Avatar
data class AvatarPickerState(
val currentAvatar: Avatar? = null,
val selectableAvatars: List<Avatar> = listOf(),
val canSave: Boolean = false,
val canClear: Boolean = false,
val isCleared: Boolean = false
)

View File

@@ -0,0 +1,198 @@
package org.thoughtcrime.securesms.avatar.picker
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.util.livedata.Store
sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val store = Store(AvatarPickerState())
val state: LiveData<AvatarPickerState> = store.stateLiveData
protected abstract fun getAvatar(): Single<Avatar>
protected abstract fun getDefaultAvatarFromRepository(): Avatar
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit)
protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit)
fun delete(avatar: Avatar) {
repository.delete(avatar) {
refreshAvatar()
refreshSelectableAvatars()
}
}
fun clear() {
store.update {
val avatar = getDefaultAvatarFromRepository()
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
}
}
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
if (store.state.isCleared) {
onCleared()
} else {
val avatar = store.state.currentAvatar ?: throw AssertionError()
persistAndCreateMedia(avatar, onSaved)
}
}
fun onAvatarSelectedFromGrid(avatar: Avatar) {
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
}
fun onAvatarEditCompleted(avatar: Avatar) {
persistAvatar(avatar) { saved ->
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
refreshSelectableAvatars()
}
}
fun onAvatarPhotoSelectionCompleted(media: Media) {
repository.writeMediaToMultiSessionStorage(media) { multiSessionUri ->
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
refreshSelectableAvatars()
}
}
}
protected fun refreshAvatar() {
disposables.add(
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
}
)
}
protected fun refreshSelectableAvatars() {
disposables.add(
Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def ->
val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key }
custom + def.filterNot {
it is Avatar.Vector && customKeys.contains(it.key)
}
}.subscribeOn(Schedulers.io()).subscribe { avatars ->
store.update { it.copy(selectableAvatars = avatars) }
}
)
}
private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
override fun onCleared() {
disposables.dispose()
}
private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) {
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> = repository.getAvatarForSelf()
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForSelf()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForSelf(avatar, onPersisted)
}
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
repository.persistAndCreateMediaForSelf(avatar, onSaved)
}
}
private class GroupAvatarPickerViewModel(
private val groupId: GroupId,
private val repository: AvatarPickerRepository,
groupAvatarMedia: Media?
) : AvatarPickerViewModel(repository) {
private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
repository.getAvatarForGroup(groupId)
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForGroup(avatar, groupId, onPersisted)
}
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved)
}
}
private class NewGroupAvatarPickerViewModel(
private val repository: AvatarPickerRepository,
initialMedia: Media?
) : AvatarPickerViewModel(repository) {
private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
Single.fromCallable { getDefaultAvatarFromRepository() }
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar)
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved)
}
class Factory(
private val repository: AvatarPickerRepository,
private val groupId: GroupId?,
private val isNewGroup: Boolean,
private val groupAvatarMedia: Media?
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
val viewModel = if (groupId == null && !isNewGroup) {
SelfAvatarPickerViewModel(repository)
} else if (groupId == null) {
NewGroupAvatarPickerViewModel(repository, groupAvatarMedia)
} else {
GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia)
}
return requireNotNull(modelClass.cast(viewModel))
}
}
}

View File

@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms.avatar.text
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import com.google.android.material.tabs.TabLayout
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerItem
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
import org.thoughtcrime.securesms.components.ControllableTabLayout
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Fragment to create an avatar based off of a Vector or Text (via a pager)
*/
class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragment) {
private val viewModel: TextAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var textInput: EditText
private lateinit var recycler: RecyclerView
private lateinit var content: ConstraintLayout
private val withRecyclerSet = ConstraintSet()
private val withoutRecyclerSet = ConstraintSet()
private var hasBoundFromViewModel: Boolean = false
private fun createFactory(): TextAvatarCreationViewModel.Factory {
val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments())
val textBundle = args.textAvatar
val text = if (textBundle != null) {
AvatarBundler.extractText(textBundle)
} else {
Avatar.Text("", Avatars.colors.random(), Avatar.DatabaseId.NotSet)
}
return TextAvatarCreationViewModel.Factory(text)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.text_avatar_creation_toolbar)
val tabLayout: ControllableTabLayout = view.findViewById(R.id.text_avatar_creation_tabs)
val doneButton: View = view.findViewById(R.id.text_avatar_creation_done)
val keyboardAwareLayout: KeyboardAwareLinearLayout = view.findViewById(R.id.keyboard_aware_layout)
withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content)
withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content_hidden_recycler)
content = view.findViewById(R.id.content)
recycler = view.findViewById(R.id.text_avatar_creation_recycler)
textInput = view.findViewById(R.id.avatar_picker_item_text)
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
BoldSelectionTabItem.registerListeners(tabLayout)
val onTabSelectedListener = OnTabSelectedListener()
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
onTabSelectedListener.onTabSelected(requireNotNull(tabLayout.getTabAt(tabLayout.selectedTabPosition)))
val adapter = MappingAdapter()
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
AvatarColorItem.registerViewHolder(adapter) {
viewModel.setColor(it)
}
recycler.adapter = adapter
val viewHolder = AvatarPickerItem.ViewHolder(view)
viewModel.state.observe(viewLifecycleOwner) { state ->
EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor)
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
hasBoundFromViewModel = true
}
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
textInput.doAfterTextChanged {
if (it != null && hasBoundFromViewModel) {
viewModel.setText(it.toString())
}
}
doneButton.setOnClickListener { v ->
setFragmentResult(REQUEST_KEY_TEXT, AvatarBundler.bundleText(viewModel.getCurrentAvatar()))
Navigation.findNavController(v).popBackStack()
}
textInput.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_NEXT) {
tabLayout.getTabAt(1)?.select()
true
} else {
false
}
}
keyboardAwareLayout.addOnKeyboardHiddenListener {
if (tabLayout.selectedTabPosition == 1) {
val transition = AutoTransition().setStartDelay(250L)
TransitionManager.endTransitions(content)
withRecyclerSet.applyTo(content)
TransitionManager.beginDelayedTransition(content, transition)
}
}
}
private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
when (tab.position) {
0 -> {
textInput.isEnabled = true
ViewUtil.focusAndShowKeyboard(textInput)
withoutRecyclerSet.applyTo(content)
textInput.setSelection(textInput.length())
}
1 -> {
textInput.isEnabled = false
ViewUtil.hideKeyboard(requireContext(), textInput)
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
}
companion object {
const val REQUEST_KEY_TEXT = "org.thoughtcrime.securesms.avatar.text.TEXT"
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.text
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class TextAvatarCreationState(
val currentAvatar: Avatar.Text,
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.avatar.text
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.util.livedata.Store
class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
private val store = Store(TextAvatarCreationState(initialText))
val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
}
fun setText(text: String) {
store.update {
if (it.currentAvatar.text == text) {
it
} else {
it.copy(currentAvatar = it.currentAvatar.copy(text = text))
}
}
}
fun getCurrentAvatar(): Avatar.Text {
return store.state.currentAvatar
}
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
}
}
}

View File

@@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.avatar.vector
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Fragment to create an avatar based off a default vector.
*/
class VectorAvatarCreationFragment : Fragment(R.layout.vector_avatar_creation_fragment) {
private val viewModel: VectorAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
private fun createFactory(): VectorAvatarCreationViewModel.Factory {
val args = VectorAvatarCreationFragmentArgs.fromBundle(requireArguments())
val vectorBundle = args.vectorAvatar
return VectorAvatarCreationViewModel.Factory(AvatarBundler.extractVector(vectorBundle))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.vector_avatar_creation_toolbar)
val recycler: RecyclerView = view.findViewById(R.id.vector_avatar_creation_recycler)
val doneButton: View = view.findViewById(R.id.vector_avatar_creation_done)
val preview: ImageView = view.findViewById(R.id.vector_avatar_creation_image)
val adapter = MappingAdapter()
recycler.adapter = adapter
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
AvatarColorItem.registerViewHolder(adapter) {
viewModel.setColor(it)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
preview.background.colorFilter = SimpleColorFilter(state.currentAvatar.color.backgroundColor)
preview.setImageResource(requireNotNull(Avatars.getDrawableResource(state.currentAvatar.key)))
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
}
toolbar.setNavigationOnClickListener { Navigation.findNavController(view).popBackStack() }
doneButton.setOnClickListener {
setFragmentResult(REQUEST_KEY_VECTOR, AvatarBundler.bundleVector(viewModel.getCurrentAvatar()))
Navigation.findNavController(it).popBackStack()
}
}
companion object {
const val REQUEST_KEY_VECTOR = "org.thoughtcrime.securesms.avatar.text.VECTOR"
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.vector
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class VectorAvatarCreationState(
val currentAvatar: Avatar.Vector,
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.avatar.vector
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.util.livedata.Store
class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel() {
private val store = Store(VectorAvatarCreationState(initialAvatar))
val state: LiveData<VectorAvatarCreationState> = store.stateLiveData
fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
}
fun getCurrentAvatar() = store.state.currentAvatar
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
}
}
}

View File

@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.database.SessionDatabase;
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -84,7 +85,8 @@ public class FullBackupExporter extends FullBackupBase {
EmojiSearchDatabase.TABLE_NAME,
SenderKeyDatabase.TABLE_NAME,
SenderKeySharedDatabase.TABLE_NAME,
PendingRetryReceiptDatabase.TABLE_NAME
PendingRetryReceiptDatabase.TABLE_NAME,
AvatarPickerDatabase.TABLE_NAME
);
public static void export(@NonNull Context context,

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.blocked;
import android.app.AlertDialog;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
@@ -8,10 +7,12 @@ import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
@@ -25,6 +26,8 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.function.Consumer;
public class BlockedUsersActivity extends PassphraseRequiredActivity implements BlockedUsersFragment.Listener, ContactSelectionListFragment.OnContactSelectedListener {
private static final String CONTACT_SELECTION_FRAGMENT = "Contact.Selection.Fragment";
@@ -84,24 +87,24 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
final String displayName = recipientId.transform(id -> Recipient.resolved(id).getDisplayName(this)).or(number);
AlertDialog confirmationDialog = new AlertDialog.Builder(BlockedUsersActivity.this)
.setTitle(R.string.BlockedUsersActivity__block_user)
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
if (recipientId.isPresent()) {
viewModel.block(recipientId.get());
} else {
viewModel.createAndBlock(number);
}
dialog.dismiss();
onBackPressed();
})
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.setCancelable(true)
.create();
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.BlockedUsersActivity__block_user)
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
if (recipientId.isPresent()) {
viewModel.block(recipientId.get());
} else {
viewModel.createAndBlock(number);
}
dialog.dismiss();
onBackPressed();
})
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.setCancelable(true)
.create();
confirmationDialog.setOnShowListener(dialog -> {
confirmationDialog.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(Color.RED);
@@ -109,7 +112,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
confirmationDialog.show();
return false;
callback.accept(false);
}
@Override

View File

@@ -346,7 +346,7 @@ public final class AudioView extends FrameLayout {
}
if (duration != null && durationMillis > 0) {
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis);
long remainingSecs = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis));
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
}

View File

@@ -106,7 +106,7 @@ public final class AvatarImageView extends AppCompatImageView {
outlinePaint = ThemeUtil.isDarkTheme(context) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN.colorInt(), inverted);
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN, inverted);
blurred = false;
chatColors = null;
}
@@ -248,7 +248,7 @@ public final class AvatarImageView extends AppCompatImageView {
requestManager.clear(this);
if (fallbackPhotoProvider != null) {
setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
.asDrawable(getContext(), AvatarColor.UNKNOWN.colorInt(), inverted));
.asDrawable(getContext(), AvatarColor.UNKNOWN, inverted));
} else {
setImageDrawable(unknownRecipientDrawable);
}
@@ -285,7 +285,7 @@ public final class AvatarImageView extends AppCompatImageView {
{
Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
.getPhotoForGroup()
.asDrawable(getContext(), color.colorInt());
.asDrawable(getContext(), color);
GlideApp.with(this)
.load(avatarBytes)

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.tabs.TabLayout
import org.thoughtcrime.securesms.R
import java.util.Objects
/**
* Custom View for Tabs which will render bold text when the view is selected
*/
class BoldSelectionTabItem @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private lateinit var unselectedTextView: TextView
private lateinit var selectedTextView: TextView
override fun onFinishInflate() {
super.onFinishInflate()
unselectedTextView = findViewById(android.R.id.text1)
selectedTextView = findViewById(R.id.text1_bold)
unselectedTextView.doAfterTextChanged {
selectedTextView.text = it
}
}
fun select() {
unselectedTextView.alpha = 0f
selectedTextView.alpha = 1f
}
fun unselect() {
unselectedTextView.alpha = 1f
selectedTextView.alpha = 0f
}
companion object {
@JvmStatic
fun registerListeners(tabLayout: ControllableTabLayout) {
val newTabListener = NewTabListener()
val onTabSelectedListener = OnTabSelectedListener()
(0 until tabLayout.tabCount).mapNotNull { tabLayout.getTabAt(it) }.forEach {
newTabListener.onNewTab(it)
if (it.isSelected) {
onTabSelectedListener.onTabSelected(it)
} else {
onTabSelectedListener.onTabUnselected(it)
}
}
tabLayout.setNewTabListener(newTabListener)
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
}
}
private class NewTabListener : ControllableTabLayout.NewTabListener {
override fun onNewTab(tab: TabLayout.Tab) {
val customView = tab.customView
if (customView == null) {
tab.setCustomView(R.layout.bold_selection_tab_item)
}
}
}
private class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
view.select()
}
override fun onTabUnselected(tab: TabLayout.Tab) {
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
view.unselect()
}
override fun onTabReselected(tab: TabLayout.Tab) = Unit
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
class ButtonStripItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val iconView: ImageView
private val labelView: TextView
init {
inflate(context, R.layout.button_strip_item_view, this)
iconView = findViewById(R.id.icon)
labelView = findViewById(R.id.label)
val array = context.obtainStyledAttributes(attrs, R.styleable.ButtonStripItemView)
val icon = array.getDrawable(R.styleable.ButtonStripItemView_bsiv_icon)
val contentDescription = array.getString(R.styleable.ButtonStripItemView_bsiv_icon_contentDescription)
val label = array.getString(R.styleable.ButtonStripItemView_bsiv_label)
iconView.setImageDrawable(icon)
iconView.contentDescription = contentDescription
labelView.text = label
array.recycle()
}
fun setOnIconClickedListener(onIconClickedListener: (() -> Unit)?) {
iconView.setOnClickListener { onIconClickedListener?.invoke() }
}
}

View File

@@ -15,6 +15,7 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.widget.TextViewCompat;
@@ -101,7 +102,6 @@ public final class ContactFilterView extends FrameLayout {
expandTapArea(toggleContainer, dialpadToggle);
applyAttributes(searchText, context, attrs, defStyleAttr);
searchText.requestFocus();
}
private void applyAttributes(@NonNull EditText searchText,
@@ -121,6 +121,11 @@ public final class ContactFilterView extends FrameLayout {
if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) {
dialpadToggle.setVisibility(GONE);
}
if (attributes.getBoolean(R.styleable.ContactFilterToolbar_cfv_autoFocus, true)) {
searchText.requestFocus();
}
attributes.recycle();
}
@@ -137,6 +142,10 @@ public final class ContactFilterView extends FrameLayout {
this.listener = listener;
}
public void setOnSearchInputFocusChangedListener(@Nullable OnFocusChangeListener listener) {
searchText.setOnFocusChangeListener(listener);
}
public void setHint(@StringRes int hint) {
searchText.setHint(hint);
}

View File

@@ -25,7 +25,6 @@ import com.airbnb.lottie.LottieProperty;
import com.airbnb.lottie.model.KeyPath;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -33,15 +32,18 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public class ConversationItemFooter extends ConstraintLayout {
@@ -63,6 +65,8 @@ public class ConversationItemFooter extends ConstraintLayout {
private final Rect speedToggleHitRect = new Rect();
private final int touchTargetSize = ViewUtil.dpToPx(48);
private long previousMessageId;
public ConversationItemFooter(Context context) {
super(context);
init(null);
@@ -146,7 +150,7 @@ public class ConversationItemFooter extends ConstraintLayout {
}
public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) {
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis);
long remainingSecs = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(totalDurationMillis - currentPostionMillis));
audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
}
@@ -307,7 +311,7 @@ public class ConversationItemFooter extends ConstraintLayout {
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
} else {
dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
}
@@ -367,6 +371,19 @@ public class ConversationItemFooter extends ConstraintLayout {
}
private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) {
long newMessageId = buildMessageId(messageRecord);
if (previousMessageId == newMessageId && deliveryStatusView.isPending() && !messageRecord.isPending()) {
if (messageRecord.getRecipient().isGroup()) {
SignalLocalMetrics.GroupMessageSend.onUiUpdated(messageRecord.getId());
} else {
SignalLocalMetrics.IndividualMessageSend.onUiUpdated(messageRecord.getId());
}
}
previousMessageId = newMessageId;
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
deliveryStatusView.setNone();
return;
@@ -400,7 +417,7 @@ public class ConversationItemFooter extends ConstraintLayout {
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
showAudioDurationViews();
if (messageRecord.getViewedReceiptCount() > 0) {
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getRecipient(), Recipient.self()))) {
revealDot.setProgress(1f);
} else {
revealDot.setProgress(0f);
@@ -425,6 +442,10 @@ public class ConversationItemFooter extends ConstraintLayout {
playbackSpeedToggleTextView.setVisibility(View.GONE);
}
private long buildMessageId(@NonNull MessageRecord record) {
return record.isMms() ? -record.getId() : record.getId();
}
public interface OnTouchDelegateChangedListener {
void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView);
}

View File

@@ -60,6 +60,10 @@ public class DeliveryStatusView extends FrameLayout {
this.setVisibility(View.GONE);
}
public boolean isPending() {
return pendingIndicator.getVisibility() == View.VISIBLE;
}
public void setPending() {
this.setVisibility(View.VISIBLE);
pendingIndicator.setVisibility(View.VISIBLE);

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Forces rounded corners on BottomSheet
*/
abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() {
protected open val peekHeightPercentage: Float = 0.5f
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.Widget_Signal_FixedRoundedCorners)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
val shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
.build()
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_primary))
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (bottomSheet.background !== dialogBackground) {
ViewCompat.setBackground(bottomSheet, dialogBackground)
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})
return dialog
}
}

View File

@@ -93,6 +93,10 @@ public class InputPanel extends LinearLayout
private @Nullable Listener listener;
private boolean emojiVisible;
private boolean hideForGroupState;
private boolean hideForBlockedState;
private boolean hideForSearch;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
public InputPanel(Context context) {
@@ -317,6 +321,21 @@ public class InputPanel extends LinearLayout
}
}
public void setHideForGroupState(boolean hideForGroupState) {
this.hideForGroupState = hideForGroupState;
updateVisibility();
}
public void setHideForBlockedState(boolean hideForBlockedState) {
this.hideForBlockedState = hideForBlockedState;
updateVisibility();
}
public void setHideForSearch(boolean hideForSearch) {
this.hideForSearch = hideForSearch;
updateVisibility();
}
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
@@ -495,6 +514,14 @@ public class InputPanel extends LinearLayout
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
}
private void updateVisibility() {
if (hideForGroupState || hideForBlockedState || hideForSearch) {
setVisibility(GONE);
} else {
setVisibility(VISIBLE);
}
}
public interface Listener extends VoiceNoteDraftView.Listener {
void onRecorderStarted();
void onRecorderLocked();

View File

@@ -143,8 +143,9 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
if (layoutManager instanceof GridLayoutManager) {
int viewWidth = w - getPaddingStart() - getPaddingEnd();
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
int spanCount = Math.max(w / idealWidth, 1);
int spanCount = Math.max(viewWidth / idealWidth, 1);
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
}

View File

@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.emoji.EmojiPageCache;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.thoughtcrime.securesms.util.ListenableFutureTask;
import java.util.concurrent.ExecutionException;
@@ -52,7 +53,7 @@ class EmojiProvider {
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) {
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo());
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout);
if (drawable != null) {
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
@@ -65,10 +66,17 @@ class EmojiProvider {
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
return getEmojiDrawable(context, drawInfo);
return getEmojiDrawable(context, drawInfo, null);
}
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo) {
/**
* Gets an EmojiDrawable from the Page Cache
*
* @param context Context object used in reading and writing from disk
* @param drawInfo Information about the emoji being displayed
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
*/
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded) {
if (drawInfo == null) {
return null;
}
@@ -77,19 +85,30 @@ class EmojiProvider {
final EmojiSource source = EmojiSource.getLatest();
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
EmojiPageCache.INSTANCE
.load(context, drawInfo.getPage(), lowMemoryDecodeScale)
.addListener(new FutureTaskListener<Bitmap>() {
@Override
public void onSuccess(Bitmap result) {
ThreadUtil.runOnMain(() -> drawable.setBitmap(result));
}
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
@Override
public void onFailure(ExecutionException exception) {
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
}
});
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
ThreadUtil.runOnMain(() -> drawable.setBitmap(((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap()));
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
((EmojiPageCache.LoadResult.Async) loadResult).getTask().addListener(new FutureTaskListener<Bitmap>() {
@Override
public void onSuccess(Bitmap result) {
ThreadUtil.runOnMain(() -> {
drawable.setBitmap(result);
if (onEmojiLoaded != null) {
onEmojiLoaded.run();
}
});
}
@Override
public void onFailure(ExecutionException exception) {
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
}
});
} else {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
return drawable;
}
@@ -122,10 +141,10 @@ class EmojiProvider {
final int xStart = (index % emojiPerRow) * glyphWidth;
final int yStart = (index / emojiPerRow) * glyphHeight;
this.emojiBounds = new Rect(xStart,
yStart,
xStart + glyphWidth,
yStart + glyphHeight);
this.emojiBounds = new Rect(xStart + 1,
yStart + 1,
xStart + glyphWidth - 1,
yStart + glyphHeight - 1);
}
@Override

View File

@@ -14,29 +14,33 @@ public class EmojiSpan extends AnimatingImageSpan {
private final float SHIFT_FACTOR = 1.5f;
private final int size;
private final FontMetricsInt fm;
private int size;
private FontMetricsInt fontMetrics;
public EmojiSpan(@NonNull Drawable drawable, @NonNull TextView tv) {
super(drawable, tv);
fm = tv.getPaint().getFontMetricsInt();
size = fm != null ? Math.abs(fm.descent) + Math.abs(fm.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
fontMetrics = tv.getPaint().getFontMetricsInt();
size = fontMetrics != null ? Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)
: tv.getResources().getDimensionPixelSize(R.dimen.conversation_item_body_text_size);
getDrawable().setBounds(0, 0, size, size);
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, FontMetricsInt fm) {
if (fm != null && this.fm != null) {
fm.ascent = this.fm.ascent;
fm.descent = this.fm.descent;
fm.top = this.fm.top;
fm.bottom = this.fm.bottom;
fm.leading = this.fm.leading;
return size;
if (fm != null && this.fontMetrics != null) {
fm.ascent = this.fontMetrics.ascent;
fm.descent = this.fontMetrics.descent;
fm.top = this.fontMetrics.top;
fm.bottom = this.fontMetrics.bottom;
fm.leading = this.fontMetrics.leading;
} else {
return super.getSize(paint, text, start, end, fm);
this.fontMetrics = paint.getFontMetricsInt();
this.size = Math.abs(this.fontMetrics.descent) + Math.abs(this.fontMetrics.ascent);
getDrawable().setBounds(0, 0, size, size);
}
return size;
}
@Override

View File

@@ -1,12 +1,16 @@
package org.thoughtcrime.securesms.components.emoji;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.text.Annotation;
import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextDirectionHeuristic;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
@@ -19,6 +23,7 @@ import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
import androidx.core.widget.TextViewCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
@@ -33,19 +38,22 @@ import java.util.List;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private final boolean forceCustom;
private static final char ELLIPSIS = '…';
private CharSequence previousText;
private BufferType previousBufferType;
private float originalFontSize;
private boolean useSystemEmoji;
private boolean sizeChangeInProgress;
private int maxLength;
private CharSequence overflowText;
private CharSequence previousOverflowText;
private boolean renderMentions;
private boolean forceCustom;
private CharSequence previousText;
private BufferType previousBufferType;
private float originalFontSize;
private boolean useSystemEmoji;
private boolean sizeChangeInProgress;
private int maxLength;
private CharSequence overflowText;
private CharSequence previousOverflowText;
private boolean renderMentions;
private boolean measureLastLine;
private int lastLineWidth = -1;
private TextDirectionHeuristic textDirection;
private MentionRendererDelegate mentionRendererDelegate;
@@ -61,10 +69,11 @@ public class EmojiTextView extends AppCompatTextView {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
measureLastLine = a.getBoolean(R.styleable.EmojiTextView_measureLastLine, false);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
@@ -74,6 +83,8 @@ public class EmojiTextView extends AppCompatTextView {
if (renderMentions) {
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
}
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
}
@Override
@@ -139,11 +150,46 @@ public class EmojiTextView extends AppCompatTextView {
}
}
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
CharSequence text = getText();
if (getLayout() == null || !measureLastLine || text == null || text.length() == 0) {
lastLineWidth = -1;
} else {
Layout layout = getLayout();
int lines = layout.getLineCount();
int start = layout.getLineStart(lines - 1);
int count = text.length() - start;
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, start, count)) ||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, start, count))) {
lastLineWidth = getMeasuredWidth();
} else {
lastLineWidth = (int) getPaint().measureText(text, start, text.length());
}
}
}
public int getLastLineWidth() {
return lastLineWidth;
}
public boolean isSingleLine() {
return getLayout() != null && getLayout().getLineCount() == 1;
}
public void setOverflowText(@Nullable CharSequence overflowText) {
this.overflowText = overflowText;
setText(previousText, BufferType.SPANNABLE);
}
public void setForceCustomEmoji(boolean forceCustom) {
if (this.forceCustom != forceCustom) {
this.forceCustom = forceCustom;
setText(previousText, BufferType.SPANNABLE);
}
}
private void ellipsizeAnyTextForMaxLength() {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.libsignal.util.guava.Optional
open class SingleLineEmojiTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var bufferType: BufferType? = null
init {
maxLines = 1
}
override fun setText(text: CharSequence?, type: BufferType?) {
bufferType = type
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
} else {
val newContent = if (width == 0) {
text
} else {
TextUtils.ellipsize(text, paint, width.toFloat(), TextUtils.TruncateAt.END, false, null)
}
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
val newText = if (newCandidates == null || newCandidates.size() == 0) {
newContent
} else {
EmojiProvider.emojify(newCandidates, newContent, this)
}
super.setText(newText, type)
}
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
if (width > 0 && oldWidth != width) {
setText(text, bufferType ?: BufferType.NORMAL)
}
}
override fun setMaxLines(maxLines: Int) {
check(maxLines == 1) { "setMaxLines: $maxLines != 1" }
super.setMaxLines(maxLines)
}
}

View File

@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.components.recyclerview
import android.graphics.Rect
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Decoration which will add an equal amount of space between each item in a grid.
*/
open class GridDividerDecoration(
private val spanCount: Int,
@Px private val space: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
return setItemOffsets(parent.getChildAdapterPosition(view), view, outRect)
}
protected fun setItemOffsets(position: Int, view: View, outRect: Rect) {
val column = position % spanCount
val isRtl = ViewUtil.isRtl(view)
val distanceFromEnd = spanCount - 1 - column
val spaceStart = (column / spanCount.toFloat()) * space
val spaceEnd = (distanceFromEnd / spanCount.toFloat()) * space
outRect.setStart(spaceStart.toInt(), isRtl)
outRect.setEnd(spaceEnd.toInt(), isRtl)
outRect.bottom = space
}
private fun Rect.setEnd(end: Int, isRtl: Boolean) {
if (isRtl) {
left = end
} else {
right = end
}
}
private fun Rect.setStart(start: Int, isRtl: Boolean) {
if (isRtl) {
right = start
} else {
left = start
}
}
}

View File

@@ -43,6 +43,11 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
}
}
override fun onResume() {
super.onResume()
viewModel.refreshState()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel = ViewModelProviders.of(this)[AccountSettingsViewModel::class.java]
@@ -70,7 +75,8 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
switchPref(
title = DSLSettingsText.from(R.string.preferences_app_protection__pin_reminders),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__youll_be_asked_less_frequently),
isChecked = state.pinRemindersEnabled,
isChecked = state.hasPin && state.pinRemindersEnabled,
isEnabled = state.hasPin,
onClick = {
setPinRemindersEnabled(!state.pinRemindersEnabled)
}
@@ -80,6 +86,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock),
summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin),
isChecked = state.registrationLockEnabled,
isEnabled = state.hasPin,
onClick = {
setRegistrationLockEnabled(!state.registrationLockEnabled)
}

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.livedata.Store
@@ -21,7 +22,7 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
useAddressBook = SignalStore.settings().isPreferSystemContactPhotos,
useSystemEmoji = SignalStore.settings().isPreferSystemEmoji,
enterKeySends = SignalStore.settings().isEnterKeySends,
chatBackupsEnabled = SignalStore.settings().isBackupEnabled
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
)
)

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
@@ -255,6 +256,18 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_local_metrics)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_clear_local_metrics),
summary = DSLSettingsText.from(R.string.preferences__internal_click_to_clear_all_local_metrics_state),
onClick = {
clearAllLocalMetricsState()
}
)
dividerPref()
sectionHeaderPref(R.string.preferences__internal_calling)
radioPref(
@@ -354,4 +367,9 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
DatabaseFactory.getSenderKeySharedDatabase(requireContext()).deleteAll()
Toast.makeText(context, "Deleted all sender key shared state.", Toast.LENGTH_SHORT).show()
}
private fun clearAllLocalMetricsState() {
LocalMetricsDatabase.getInstance(ApplicationDependencies.getApplication()).clear()
Toast.makeText(context, "Cleared all local metrics state.", Toast.LENGTH_SHORT).show()
}
}

View File

@@ -48,6 +48,6 @@ class ExpireTimerSettingsRepository(val context: Context) {
private fun getThreadId(recipientId: RecipientId): Long {
val threadDatabase: ThreadDatabase = DatabaseFactory.getThreadDatabase(context)
val recipient: Recipient = Recipient.resolved(recipientId)
return threadDatabase.getThreadIdFor(recipient)
return threadDatabase.getOrCreateThreadIdFor(recipient)
}
}

View File

@@ -19,7 +19,7 @@ class ExpireTimerSettingsViewModel(val config: Config, private val repository: E
init {
if (recipientId != null) {
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expireMessages, isForRecipient = true) }
store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expiresInSeconds, isForRecipient = true) }
} else {
store.update { it.copy(initialTimer = config.initialValue ?: SignalStore.settings().universalExpireTimer) }
}

View File

@@ -15,6 +15,7 @@ sealed class ConversationSettingsEvent {
val groupId: GroupId,
val selectionWarning: Int,
val selectionLimit: Int,
val isAnnouncementGroup: Boolean,
val groupMembersWithoutSelf: List<RecipientId>
) : ConversationSettingsEvent()

View File

@@ -237,11 +237,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
AvatarPreference.Model(
recipient = state.recipient,
onAvatarClick = { avatar ->
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
AvatarPreviewActivity.createTransitionBundle(this, avatar)
)
if (!state.recipient.isSelf) {
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
AvatarPreviewActivity.createTransitionBundle(this, avatar)
)
}
}
}
)
@@ -315,7 +317,15 @@ class ConversationSettingsFragment : DSLSettingsFragment(
ButtonStripPreference.Model(
state = state.buttonStripState,
onVideoClick = {
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
} else {
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
}
},
onAudioClick = {
CommunicationActions.startVoiceCall(requireActivity(), state.recipient)
@@ -664,6 +674,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
ContactsCursorLoader.DisplayMode.FLAG_PUSH,
addMembersToGroup.selectionWarning,
addMembersToGroup.selectionLimit,
addMembersToGroup.isAnnouncementGroup,
addMembersToGroup.groupMembersWithoutSelf
),
REQUEST_CODE_ADD_MEMBERS_TO_GROUP

View File

@@ -13,11 +13,13 @@ import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.GroupProtoUtil
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.libsignal.util.guava.Preconditions
import java.io.IOException
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
@@ -130,9 +133,9 @@ class ConversationSettingsRepository(
members.addAll(groupRecord.members)
members.addAll(pendingMembers)
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits())
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits(), groupRecord.isAnnouncementGroup)
} else {
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits())
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits(), false)
}
)
}
@@ -140,6 +143,25 @@ class ConversationSettingsRepository(
fun addMembers(groupId: GroupId, selected: List<RecipientId>, consumer: (GroupAddMembersResult) -> Unit) {
SignalExecutors.BOUNDED.execute {
val record: GroupDatabase.GroupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get()
if (record.isAnnouncementGroup) {
val needsResolve = selected
.map { Recipient.resolved(it) }
.filter { it.announcementGroupCapability != Recipient.Capability.SUPPORTED && !it.isSelf }
.map { it.id }
.toSet()
ApplicationDependencies.getJobManager().runSynchronously(RetrieveProfileJob(needsResolve), TimeUnit.SECONDS.toMillis(10))
val updatedWithCapabilities = needsResolve.map { Recipient.resolved(it) }
if (updatedWithCapabilities.any { it.announcementGroupCapability != Recipient.Capability.SUPPORTED }) {
consumer(GroupAddMembersResult.Failure(GroupChangeFailureReason.NOT_ANNOUNCEMENT_CAPABLE))
return@execute
}
}
consumer(
try {
val groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected)

View File

@@ -75,7 +75,8 @@ sealed class SpecificSettingsState {
private val groupDescriptionLoaded: Boolean = false,
val groupLinkEnabled: Boolean = false,
val membershipCountDescription: String = "",
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE,
val isAnnouncementGroup: Boolean = false
) : SpecificSettingsState() {
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded

View File

@@ -144,7 +144,7 @@ sealed class ConversationSettingsViewModel(
isMuteAvailable = !recipient.isSelf,
isSearchAvailable = true
),
disappearingMessagesLifespan = recipient.expireMessages,
disappearingMessagesLifespan = recipient.expiresInSeconds,
canModifyBlockedState = !recipient.isSelf,
specificSettingsState = state.requireRecipientSettingsState().copy(
contactLinkState = when {
@@ -322,6 +322,14 @@ sealed class ConversationSettingsViewModel(
)
}
store.update(liveGroup.isAnnouncementGroup) { announcementGroup, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
isAnnouncementGroup = announcementGroup
)
)
}
val isMessageRequestAccepted: LiveData<Boolean> = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) }
val descriptionState: LiveData<DescriptionState> = LiveDataUtil.combineLatest(liveGroup.description, isMessageRequestAccepted, ::DescriptionState)
@@ -386,11 +394,13 @@ sealed class ConversationSettingsViewModel(
override fun onAddToGroup() {
repository.getGroupCapacity(groupId) { capacityResult ->
if (capacityResult.getRemainingCapacity() > 0) {
internalEvents.postValue(
ConversationSettingsEvent.AddMembersToGroup(
groupId,
capacityResult.getSelectionWarning(),
capacityResult.getSelectionLimit(),
capacityResult.isAnnouncementGroup,
capacityResult.getMembersWithoutSelf()
)
)

View File

@@ -7,7 +7,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
class GroupCapacityResult(
private val selfId: RecipientId,
private val members: List<RecipientId>,
private val selectionLimits: SelectionLimits
private val selectionLimits: SelectionLimits,
val isAnnouncementGroup: Boolean
) {
fun getMembers(): List<RecipientId?> {
return members

View File

@@ -72,6 +72,20 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
viewModel.setNonAdminCanEditGroupInfo(it == 1)
}
)
if (state.announcementGroupPermissionEnabled) {
radioListPref(
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__send_messages),
isEnabled = state.selfCanEditSettings,
listItems = permissionsOptions,
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_send_messages),
selected = getSelected(!state.announcementGroup),
confirmAction = true,
onSelected = {
viewModel.setAnnouncementGroup(it == 0)
}
)
}
}
}

View File

@@ -42,4 +42,18 @@ class PermissionsSettingsRepository(private val context: Context) {
}
}
}
fun applyAnnouncementGroupChange(groupId: GroupId, isAnnouncementGroup: Boolean, error: GroupChangeErrorCallback) {
SignalExecutors.UNBOUNDED.execute {
try {
GroupManager.applyAnnouncementGroupChange(context, groupId.requireV2(), isAnnouncementGroup)
} catch (e: GroupChangeException) {
Log.w(TAG, e)
error.onError(GroupChangeFailureReason.fromException(e))
} catch (e: IOException) {
Log.w(TAG, e)
error.onError(GroupChangeFailureReason.fromException(e))
}
}
}
}

View File

@@ -3,5 +3,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.permissions
data class PermissionsSettingsState(
val selfCanEditSettings: Boolean = false,
val nonAdminCanAddMembers: Boolean = false,
val nonAdminCanEditGroupInfo: Boolean = false
val nonAdminCanEditGroupInfo: Boolean = false,
val announcementGroupPermissionEnabled: Boolean = false,
val announcementGroup: Boolean = false
)

View File

@@ -6,6 +6,8 @@ import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.groups.GroupAccessControl
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
@@ -33,6 +35,18 @@ class PermissionsSettingsViewModel(
store.update(liveGroup.attributesAccessControl) { attributesAccessControl, state ->
state.copy(nonAdminCanEditGroupInfo = attributesAccessControl == GroupAccessControl.ALL_MEMBERS)
}
store.update(liveGroup.isAnnouncementGroup) { isAnnouncementGroup, state ->
state.copy(
announcementGroup = isAnnouncementGroup,
announcementGroupPermissionEnabled = state.announcementGroupPermissionEnabled || isAnnouncementGroup
)
}
store.update(liveGroup.groupRecipient) { groupRecipient, state ->
val allHaveCapability = groupRecipient.participants.map { it.announcementGroupCapability }.all { it == Recipient.Capability.SUPPORTED }
state.copy(announcementGroupPermissionEnabled = (FeatureFlags.announcementGroups() && allHaveCapability) || state.announcementGroup)
}
}
fun setNonAdminCanAddMembers(nonAdminCanAddMembers: Boolean) {
@@ -47,6 +61,12 @@ class PermissionsSettingsViewModel(
}
}
fun setAnnouncementGroup(announcementGroup: Boolean) {
repository.applyAnnouncementGroupChange(groupId, announcementGroup) { reason ->
internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason))
}
}
private fun Boolean.asGroupAccessControl(): GroupAccessControl {
return if (this) {
GroupAccessControl.ALL_MEMBERS

View File

@@ -5,9 +5,12 @@ import androidx.core.view.ViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Renders a large avatar (80dp) for a given Recipient.
@@ -34,6 +37,7 @@ object AvatarPreference {
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
ViewCompat.setTransitionName(this, "avatar")
setFallbackPhotoProvider(AvatarPreferenceFallbackPhotoProvider())
}
override fun bind(model: Model) {
@@ -42,4 +46,10 @@ object AvatarPreference {
avatar.setOnClickListener { model.onAvatarClick(avatar) }
}
}
private class AvatarPreferenceFallbackPhotoProvider : Recipient.FallbackPhotoProvider() {
override fun getPhotoForGroup(): FallbackContactPhoto {
return FallbackPhoto(R.drawable.ic_group_outline_40, ViewUtil.dpToPx(8))
}
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.R
@@ -13,7 +14,6 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment
class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
@@ -110,7 +110,8 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
summary = DSLSettingsText.from(customSoundSummary),
onClick = {
CustomNotificationsDialogFragment.create(state.recipientId).show(parentFragmentManager, null)
val action = SoundsAndNotificationsSettingsFragmentDirections.actionSoundsAndNotificationsSettingsFragmentToCustomNotificationsSettingsFragment(state.recipientId)
Navigation.findNavController(requireView()).navigate(action)
}
)
}

View File

@@ -0,0 +1,166 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.RingtoneUtil
class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomNotificationsDialogFragment__custom_notifications) {
private val vibrateLabels: Array<String> by lazy {
resources.getStringArray(R.array.recipient_vibrate_entries)
}
private val viewModel: CustomNotificationsSettingsViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var callSoundResultLauncher: ActivityResultLauncher<Intent>
private lateinit var messageSoundResultLauncher: ActivityResultLauncher<Intent>
private fun createFactory(): CustomNotificationsSettingsViewModel.Factory {
val recipientId = CustomNotificationsSettingsFragmentArgs.fromBundle(requireArguments()).recipientId
val repository = CustomNotificationsSettingsRepository(requireContext())
return CustomNotificationsSettingsViewModel.Factory(recipientId, repository)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
messageSoundResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
handleResult(result, viewModel::setMessageSound)
}
callSoundResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
handleResult(result, viewModel::setCallSound)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun handleResult(result: ActivityResult, resultHandler: (Uri?) -> Unit) {
val resultCode = result.resultCode
val data = result.data
if (resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
resultHandler(uri)
}
}
private fun getConfiguration(state: CustomNotificationsSettingsState): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.CustomNotificationsDialogFragment__messages)
if (NotificationChannels.supported()) {
switchPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__use_custom_notifications),
isEnabled = state.isInitialLoadComplete,
isChecked = state.hasCustomNotifications,
onClick = { viewModel.setHasCustomNotifications(!state.hasCustomNotifications) }
)
}
clickPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__notification_sound),
summary = DSLSettingsText.from(getRingtoneSummary(requireContext(), state.messageSound, Settings.System.DEFAULT_NOTIFICATION_URI)),
isEnabled = state.controlsEnabled,
onClick = { requestSound(state.messageSound, false) }
)
if (NotificationChannels.supported()) {
switchPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
isChecked = state.messageVibrateEnabled,
onClick = { viewModel.setMessageVibrate(RecipientDatabase.VibrateState.fromBoolean(!state.messageVibrateEnabled)) }
)
} else {
radioListPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
listItems = vibrateLabels,
selected = state.messageVibrateState.id,
onSelected = {
viewModel.setMessageVibrate(RecipientDatabase.VibrateState.fromId(it))
}
)
}
if (state.showCallingOptions) {
dividerPref()
sectionHeaderPref(R.string.CustomNotificationsDialogFragment__call_settings)
clickPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__ringtone),
summary = DSLSettingsText.from(getRingtoneSummary(requireContext(), state.callSound, Settings.System.DEFAULT_RINGTONE_URI)),
isEnabled = state.controlsEnabled,
onClick = { requestSound(state.callSound, true) }
)
radioListPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
listItems = vibrateLabels,
selected = state.callVibrateState.id,
onSelected = {
viewModel.setCallVibrate(RecipientDatabase.VibrateState.fromId(it))
}
)
}
}
}
private fun getRingtoneSummary(context: Context, ringtone: Uri?, defaultNotificationUri: Uri?): String {
if (ringtone == null || ringtone == defaultNotificationUri) {
return context.getString(R.string.CustomNotificationsDialogFragment__default)
} else if (ringtone.toString().isEmpty()) {
return context.getString(R.string.preferences__silent)
} else {
val tone = RingtoneUtil.getRingtone(requireContext(), ringtone)
if (tone != null) {
return tone.getTitle(context)
}
}
return context.getString(R.string.CustomNotificationsDialogFragment__default)
}
private fun requestSound(current: Uri?, forCalls: Boolean) {
val existing: Uri? = when {
current == null -> getDefaultSound(forCalls)
current.toString().isEmpty() -> null
else -> current
}
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, if (forCalls) RingtoneManager.TYPE_RINGTONE else RingtoneManager.TYPE_NOTIFICATION)
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)
}
if (forCalls) {
callSoundResultLauncher.launch(intent)
} else {
messageSoundResultLauncher.launch(intent)
}
}
private fun getDefaultSound(forCalls: Boolean) = if (forCalls) Settings.System.DEFAULT_RINGTONE_URI else Settings.System.DEFAULT_NOTIFICATION_URI
}

View File

@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor
class CustomNotificationsSettingsRepository(context: Context) {
private val context = context.applicationContext
private val executor = SerialExecutor(SignalExecutors.BOUNDED)
fun initialize(recipientId: RecipientId, onInitializationComplete: () -> Unit) {
executor.execute {
val recipient = Recipient.resolved(recipientId)
val database = DatabaseFactory.getRecipientDatabase(context)
if (NotificationChannels.supported() && recipient.notificationChannel != null) {
database.setMessageRingtone(recipient.id, NotificationChannels.getMessageRingtone(context, recipient))
database.setMessageVibrate(recipient.id, RecipientDatabase.VibrateState.fromBoolean(NotificationChannels.getMessageVibrate(context, recipient)))
NotificationChannels.ensureCustomChannelConsistency(context)
}
onInitializationComplete()
}
}
fun setHasCustomNotifications(recipientId: RecipientId, hasCustomNotifications: Boolean) {
executor.execute {
if (hasCustomNotifications) {
createCustomNotificationChannel(recipientId)
} else {
deleteCustomNotificationChannel(recipientId)
}
}
}
fun setMessageVibrate(recipientId: RecipientId, vibrateState: RecipientDatabase.VibrateState) {
executor.execute {
val recipient: Recipient = Recipient.resolved(recipientId)
DatabaseFactory.getRecipientDatabase(context).setMessageVibrate(recipient.id, vibrateState)
NotificationChannels.updateMessageVibrate(context, recipient, vibrateState)
}
}
fun setCallingVibrate(recipientId: RecipientId, vibrateState: RecipientDatabase.VibrateState) {
executor.execute {
DatabaseFactory.getRecipientDatabase(context).setCallVibrate(recipientId, vibrateState)
}
}
fun setMessageSound(recipientId: RecipientId, sound: Uri?) {
executor.execute {
val recipient: Recipient = Recipient.resolved(recipientId)
val defaultValue = SignalStore.settings().messageNotificationSound
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
DatabaseFactory.getRecipientDatabase(context).setMessageRingtone(recipient.id, newValue)
NotificationChannels.updateMessageRingtone(context, recipient, newValue)
}
}
fun setCallSound(recipientId: RecipientId, sound: Uri?) {
executor.execute {
val defaultValue = SignalStore.settings().callRingtone
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
DatabaseFactory.getRecipientDatabase(context).setCallRingtone(recipientId, newValue)
}
}
@WorkerThread
private fun createCustomNotificationChannel(recipientId: RecipientId) {
val recipient: Recipient = Recipient.resolved(recipientId)
val channelId = NotificationChannels.createChannelFor(context, recipient)
DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.id, channelId)
}
@WorkerThread
private fun deleteCustomNotificationChannel(recipientId: RecipientId) {
val recipient: Recipient = Recipient.resolved(recipientId)
DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.id, null)
NotificationChannels.deleteChannelFor(context, recipient)
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.net.Uri
import org.thoughtcrime.securesms.database.RecipientDatabase
data class CustomNotificationsSettingsState(
val isInitialLoadComplete: Boolean = false,
val hasCustomNotifications: Boolean = false,
val controlsEnabled: Boolean = false,
val messageVibrateState: RecipientDatabase.VibrateState = RecipientDatabase.VibrateState.DEFAULT,
val messageVibrateEnabled: Boolean = false,
val messageSound: Uri? = null,
val callVibrateState: RecipientDatabase.VibrateState = RecipientDatabase.VibrateState.DEFAULT,
val callSound: Uri? = null,
val showCallingOptions: Boolean = false,
)

View File

@@ -0,0 +1,80 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
class CustomNotificationsSettingsViewModel(
private val recipientId: RecipientId,
private val repository: CustomNotificationsSettingsRepository
) : ViewModel() {
private val store = Store(CustomNotificationsSettingsState())
val state: LiveData<CustomNotificationsSettingsState> = store.stateLiveData
init {
repository.initialize(recipientId) {
store.update {
it.copy(
isInitialLoadComplete = true,
controlsEnabled = (!NotificationChannels.supported() || it.hasCustomNotifications)
)
}
}
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
val recipientHasCustomNotifications = NotificationChannels.supported() && recipient.notificationChannel != null
state.copy(
hasCustomNotifications = recipientHasCustomNotifications,
controlsEnabled = (!NotificationChannels.supported() || recipientHasCustomNotifications) && state.isInitialLoadComplete,
messageSound = recipient.messageRingtone,
messageVibrateState = recipient.messageVibrate,
messageVibrateEnabled = when (recipient.messageVibrate) {
RecipientDatabase.VibrateState.DEFAULT -> SignalStore.settings().isMessageVibrateEnabled
RecipientDatabase.VibrateState.ENABLED -> true
RecipientDatabase.VibrateState.DISABLED -> false
},
showCallingOptions = !recipient.isGroup && recipient.isRegistered,
callSound = recipient.callRingtone,
callVibrateState = recipient.callVibrate
)
}
}
fun setHasCustomNotifications(hasCustomNotifications: Boolean) {
repository.setHasCustomNotifications(recipientId, hasCustomNotifications)
}
fun setMessageVibrate(messageVibrateState: RecipientDatabase.VibrateState) {
repository.setMessageVibrate(recipientId, messageVibrateState)
}
fun setMessageSound(uri: Uri?) {
repository.setMessageSound(recipientId, uri)
}
fun setCallVibrate(callVibrateState: RecipientDatabase.VibrateState) {
repository.setCallingVibrate(recipientId, callVibrateState)
}
fun setCallSound(uri: Uri?) {
repository.setCallSound(recipientId, uri)
}
class Factory(
private val recipientId: RecipientId,
private val repository: CustomNotificationsSettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(CustomNotificationsSettingsViewModel(recipientId, repository)))
}
}
}

View File

@@ -57,6 +57,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
private ProgressEventHandler progressEventHandler;
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
private VoiceNoteProximityWakeLockManager voiceNoteProximityWakeLockManager;
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
@@ -108,7 +109,9 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
@Override
public void onStart(@NonNull LifecycleOwner owner) {
mediaBrowser.connect();
if (!mediaBrowser.isConnected()) {
mediaBrowser.connect();
}
}
@Override
@@ -123,6 +126,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
if (MediaControllerCompat.getMediaController(activity) != null) {
MediaControllerCompat.getMediaController(activity).unregisterCallback(mediaControllerCompatCallback);
}
mediaBrowser.disconnect();
}
@@ -275,6 +279,9 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
}
}
cleanUpOldProximityWakeLockManager();
voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController);
mediaController.registerCallback(mediaControllerCompatCallback);
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
@@ -282,6 +289,26 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
Log.w(TAG, "onConnected: Failed to set media controller", e);
}
}
@Override
public void onConnectionSuspended() {
Log.d(TAG, "Voice note MediaBrowser connection suspended.");
cleanUpOldProximityWakeLockManager();
}
@Override
public void onConnectionFailed() {
Log.d(TAG, "Voice note MediaBrowser connection failed.");
cleanUpOldProximityWakeLockManager();
}
private void cleanUpOldProximityWakeLockManager() {
if (voiceNoteProximityWakeLockManager != null) {
Log.d(TAG, "Session reconnected, cleaning up old wake lock manager");
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease();
voiceNoteProximityWakeLockManager = null;
}
}
}
private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) {

View File

@@ -1,15 +1,20 @@
package org.thoughtcrime.securesms.components.voice
import android.media.AudioManager
import android.os.Bundle
import android.os.ResultReceiver
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController
import com.google.android.exoplayer2.util.Util
class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters) : DefaultPlaybackController() {
override fun getCommands(): Array<String> {
return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED)
return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM)
}
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) {
@@ -18,6 +23,24 @@ class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: Voice
player.playbackParameters = PlaybackParameters(speed)
voiceNotePlaybackParameters.setSpeed(speed)
} else if (command == VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) {
val newStreamType: Int = extras?.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) ?: AudioManager.STREAM_MUSIC
val currentStreamType = Util.getStreamTypeForAudioUsage((player as SimpleExoPlayer).audioAttributes.usage)
if (newStreamType != currentStreamType) {
val attributes = when (newStreamType) {
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
else -> throw AssertionError()
}
player.playWhenReady = false
player.audioAttributes = attributes
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
player.playWhenReady = true
}
}
}
}
}

View File

@@ -56,6 +56,7 @@ import java.util.List;
public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed";
public static final String ACTION_SET_AUDIO_STREAM = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.set_audio_stream";
private static final String TAG = Log.tag(VoiceNotePlaybackService.class);
private static final String EMPTY_ROOT_ID = "empty-root-id";
@@ -76,7 +77,6 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
private VoiceNoteNotificationManager voiceNoteNotificationManager;
private VoiceNoteQueueDataAdapter queueDataAdapter;
private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer;
private VoiceNoteProximityManager voiceNoteProximityManager;
private boolean isForegroundService;
private VoiceNotePlaybackParameters voiceNotePlaybackParameters;
@@ -109,7 +109,6 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this);
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory, voiceNotePlaybackParameters);
voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter);
mediaSession.setPlaybackState(stateBuilder.build());
@@ -171,15 +170,12 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
if (!playWhenReady) {
stopForeground(false);
becomingNoisyReceiver.unregister();
voiceNoteProximityManager.onPlayerEnded();
} else {
sendViewedReceiptForCurrentWindowIndex();
becomingNoisyReceiver.register();
voiceNoteProximityManager.onPlayerReady();
}
break;
default:
voiceNoteProximityManager.onPlayerEnded();
becomingNoisyReceiver.unregister();
voiceNoteNotificationManager.hideNotification();
}
@@ -219,7 +215,6 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
@Override
public void onPlayerError(ExoPlaybackException error) {
Log.w(TAG, "ExoPlayer error occurred:", error);
voiceNoteProximityManager.onPlayerError();
}
}

View File

@@ -99,6 +99,7 @@ class VoiceNotePlayerView @JvmOverloads constructor(
background.colorFilter = SimpleColorFilter(ContextCompat.getColor(context, R.color.voice_note_player_view_background))
}
contentDescription = context.getString(R.string.VoiceNotePlayerView__navigate_to_voice_message)
setOnClickListener {
lastState?.let {
listener?.onNavigateToMessage(it.threadId, it.threadRecipientId, it.senderId, it.messageTimestamp, it.messagePositionInThread)
@@ -171,6 +172,12 @@ class VoiceNotePlayerView @JvmOverloads constructor(
}
lottieDirection = direction
playPauseToggleView.contentDescription = if (direction == TO_PLAY) {
context.getString(R.string.VoiceNotePlayerView__play_voice_message)
} else {
context.getString(R.string.VoiceNotePlayerView__pause_voice_message)
}
playPauseToggleView.pauseAnimation()
playPauseToggleView.speed = (direction * 2).toFloat()
playPauseToggleView.resumeAnimation()

View File

@@ -1,146 +0,0 @@
package org.thoughtcrime.securesms.components.voice;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.AudioManager;
import android.os.Build;
import android.os.PowerManager;
import android.support.v4.media.MediaDescriptionCompat;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.audio.AudioAttributes;
import com.google.android.exoplayer2.util.Util;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.util.concurrent.TimeUnit;
class VoiceNoteProximityManager implements SensorEventListener {
private static final String TAG = Log.tag(VoiceNoteProximityManager.class);
private static final float PROXIMITY_THRESHOLD = 5f;
private final SimpleExoPlayer player;
private final AudioManager audioManager;
private final SensorManager sensorManager;
private final Sensor proximitySensor;
private final PowerManager.WakeLock wakeLock;
private final VoiceNoteQueueDataAdapter queueDataAdapter;
private long startTime;
VoiceNoteProximityManager(@NonNull Context context,
@NonNull SimpleExoPlayer player,
@NonNull VoiceNoteQueueDataAdapter queueDataAdapter)
{
this.player = player;
this.audioManager = ServiceUtil.getAudioManager(context);
this.sensorManager = ServiceUtil.getSensorManager(context);
this.proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
this.queueDataAdapter = queueDataAdapter;
if (Build.VERSION.SDK_INT >= 21) {
this.wakeLock = ServiceUtil.getPowerManager(context).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG);
} else {
this.wakeLock = null;
}
}
void onPlayerReady() {
startTime = System.currentTimeMillis();
sensorManager.registerListener(this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
}
void onPlayerEnded() {
sensorManager.unregisterListener(this);
if (wakeLock != null && wakeLock.isHeld() && Build.VERSION.SDK_INT >= 21) {
wakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY);
}
}
void onPlayerError() {
onPlayerEnded();
}
@Override
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() != Sensor.TYPE_PROXIMITY || player.getPlaybackState() != Player.STATE_READY) {
return;
}
final int desiredStreamType;
if (event.values[0] < PROXIMITY_THRESHOLD && event.values[0] != proximitySensor.getMaximumRange()) {
desiredStreamType = AudioManager.STREAM_VOICE_CALL;
} else {
desiredStreamType = AudioManager.STREAM_MUSIC;
}
final int currentStreamType = Util.getStreamTypeForAudioUsage(player.getAudioAttributes().usage);
final long threadId;
final int windowIndex = player.getCurrentWindowIndex();
if (queueDataAdapter.isEmpty() || windowIndex == C.INDEX_UNSET) {
threadId = -1;
} else {
MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(windowIndex);
if (mediaDescriptionCompat.getExtras() == null) {
threadId = -1;
} else {
threadId = mediaDescriptionCompat.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID, -1);
}
}
if (desiredStreamType == AudioManager.STREAM_VOICE_CALL &&
desiredStreamType != currentStreamType &&
!audioManager.isWiredHeadsetOn() &&
threadId != -1 &&
ApplicationDependencies.getMessageNotifier().getVisibleThread() == threadId)
{
if (wakeLock != null && !wakeLock.isHeld()) {
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30));
}
player.setPlayWhenReady(false);
player.setAudioAttributes(new AudioAttributes.Builder()
.setContentType(C.CONTENT_TYPE_SPEECH)
.setUsage(C.USAGE_VOICE_COMMUNICATION)
.build());
player.setPlayWhenReady(true);
startTime = System.currentTimeMillis();
} else if (desiredStreamType == AudioManager.STREAM_MUSIC &&
desiredStreamType != currentStreamType &&
System.currentTimeMillis() - startTime > 500)
{
if (wakeLock != null) {
if (wakeLock.isHeld()) {
wakeLock.release();
}
player.setPlayWhenReady(false);
player.setAudioAttributes(new AudioAttributes.Builder()
.setContentType(C.CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build(),
true);
}
}
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
}

View File

@@ -0,0 +1,142 @@
package org.thoughtcrime.securesms.components.voice
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.ServiceUtil
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(VoiceNoteProximityWakeLockManager::class.java)
private const val PROXIMITY_THRESHOLD = 5f
/**
* Manages the WakeLock while a VoiceNote is playing back in the target activity.
*/
class VoiceNoteProximityWakeLockManager(
private val activity: AppCompatActivity,
private val mediaController: MediaControllerCompat
) : DefaultLifecycleObserver {
private val wakeLock: PowerManager.WakeLock? = if (Build.VERSION.SDK_INT >= 21) {
ServiceUtil.getPowerManager(activity).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
} else {
null
}
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
private val mediaControllerCallback = MediaControllerCallback()
private val hardwareSensorEventListener = HardwareSensorEventListener()
private var startTime: Long = -1
init {
if (proximitySensor != null) {
activity.lifecycle.addObserver(this)
}
}
override fun onResume(owner: LifecycleOwner) {
if (proximitySensor != null) {
mediaController.registerCallback(mediaControllerCallback)
}
}
override fun onPause(owner: LifecycleOwner) {
if (proximitySensor != null) {
unregisterCallbacksAndRelease()
}
}
fun unregisterCallbacksAndRelease() {
mediaController.unregisterCallback(mediaControllerCallback)
cleanUpWakeLock()
}
private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
private fun isPlayerActive() = mediaController.playbackState.state == PlaybackStateCompat.STATE_BUFFERING ||
mediaController.playbackState.state == PlaybackStateCompat.STATE_PLAYING
private fun cleanUpWakeLock() {
startTime = -1L
sensorManager.unregisterListener(hardwareSensorEventListener)
if (wakeLock?.isHeld == true) {
wakeLock.release()
}
sendNewStreamTypeToPlayer(AudioManager.STREAM_MUSIC)
}
private fun sendNewStreamTypeToPlayer(newStreamType: Int) {
val params = Bundle()
params.putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, newStreamType)
mediaController.sendCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, params, null)
}
inner class MediaControllerCallback : MediaControllerCompat.Callback() {
override fun onPlaybackStateChanged(state: PlaybackStateCompat) {
if (!isActivityResumed()) {
return
}
if (isPlayerActive()) {
if (startTime == -1L) {
startTime = System.currentTimeMillis()
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
}
} else {
cleanUpWakeLock()
}
}
}
inner class HardwareSensorEventListener : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
if (startTime == -1L ||
System.currentTimeMillis() - startTime <= 500 ||
!isActivityResumed() ||
!isPlayerActive() ||
event.sensor.type != Sensor.TYPE_PROXIMITY
) {
return
}
val newStreamType = if (event.values[0] < PROXIMITY_THRESHOLD && event.values[0] != proximitySensor?.maximumRange) {
AudioManager.STREAM_VOICE_CALL
} else {
AudioManager.STREAM_MUSIC
}
sendNewStreamTypeToPlayer(newStreamType)
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
if (wakeLock?.isHeld == false) {
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
}
startTime = System.currentTimeMillis()
} else {
if (wakeLock?.isHeld == true) {
wakeLock.release()
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
}

View File

@@ -3,12 +3,11 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.graphics.Point;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.webrtc.EglBase;
import org.webrtc.VideoFrame;
import org.webrtc.VideoSink;
import java.util.Objects;
import java.util.WeakHashMap;
/**
@@ -20,17 +19,19 @@ import java.util.WeakHashMap;
*/
public class BroadcastVideoSink implements VideoSink {
private final EglBase eglBase;
public static final int DEVICE_ROTATION_IGNORE = -1;
private final EglBaseWrapper eglBase;
private final WeakHashMap<VideoSink, Boolean> sinks;
private final WeakHashMap<Object, Point> requestingSizes;
private boolean dirtySizes;
private int deviceOrientationDegrees;
private boolean rotateToRightSide;
private boolean forceRotate;
private boolean rotateWithDevice;
private RequestedSize currentlyRequestedMaxSize;
public BroadcastVideoSink() {
this(null, false, true, 0);
this(new EglBaseWrapper(null), false, true, 0);
}
/**
@@ -39,18 +40,17 @@ public class BroadcastVideoSink implements VideoSink {
* @param rotateWithDevice Rotate video frame to match device orientation
* @param deviceOrientationDegrees Device orientation in degrees
*/
public BroadcastVideoSink(@Nullable EglBase eglBase, boolean forceRotate, boolean rotateWithDevice, int deviceOrientationDegrees) {
public BroadcastVideoSink(@NonNull EglBaseWrapper eglBase, boolean forceRotate, boolean rotateWithDevice, int deviceOrientationDegrees) {
this.eglBase = eglBase;
this.sinks = new WeakHashMap<>();
this.requestingSizes = new WeakHashMap<>();
this.dirtySizes = true;
this.deviceOrientationDegrees = deviceOrientationDegrees;
this.rotateToRightSide = false;
this.forceRotate = forceRotate;
this.rotateWithDevice = rotateWithDevice;
}
public @Nullable EglBase getEglBase() {
public @NonNull EglBaseWrapper getLockableEglBase() {
return eglBase;
}
@@ -85,7 +85,10 @@ public class BroadcastVideoSink implements VideoSink {
@Override
public synchronized void onFrame(@NonNull VideoFrame videoFrame) {
if (videoFrame.getRotatedHeight() < videoFrame.getRotatedWidth() || forceRotate) {
boolean isDeviceRotationIgnored = deviceOrientationDegrees == DEVICE_ROTATION_IGNORE;
boolean isWideVideoFrame = videoFrame.getRotatedHeight() < videoFrame.getRotatedWidth();
if (!isDeviceRotationIgnored && (isWideVideoFrame || forceRotate)) {
int rotation = calculateRotation();
if (rotation > 0) {
rotation += rotateWithDevice ? videoFrame.getRotation() : 0;
@@ -115,16 +118,18 @@ public class BroadcastVideoSink implements VideoSink {
}
void putRequestingSize(@NonNull Object object, @NonNull Point size) {
if (size.x == 0 || size.y == 0) {
return;
}
synchronized (requestingSizes) {
requestingSizes.put(object, size);
dirtySizes = true;
}
}
void removeRequestingSize(@NonNull Object object) {
synchronized (requestingSizes) {
requestingSizes.remove(object);
dirtySizes = true;
}
}
@@ -144,15 +149,15 @@ public class BroadcastVideoSink implements VideoSink {
return new RequestedSize(width, height);
}
public void newSizeRequested() {
dirtySizes = false;
public void setCurrentlyRequestedMaxSize(@NonNull RequestedSize currentlyRequestedMaxSize) {
this.currentlyRequestedMaxSize = currentlyRequestedMaxSize;
}
public boolean needsNewRequestingSize() {
return dirtySizes;
return !getMaxRequestingSize().equals(currentlyRequestedMaxSize);
}
public static class RequestedSize {
public static final class RequestedSize {
private final int width;
private final int height;
@@ -168,5 +173,19 @@ public class BroadcastVideoSink implements VideoSink {
public int getHeight() {
return height;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final RequestedSize that = (RequestedSize) o;
return width == that.width && height == that.height;
}
@Override
public int hashCode() {
return Objects.hash(width, height);
}
}
}

View File

@@ -105,6 +105,10 @@ public class CallParticipantView extends ConstraintLayout {
renderer.setScalingType(scalingType);
}
void setScalingType(@NonNull RendererCommon.ScalingType scalingTypeMatchOrientation, @NonNull RendererCommon.ScalingType scalingTypeMismatchOrientation) {
renderer.setScalingType(scalingTypeMatchOrientation, scalingTypeMismatchOrientation);
}
void setCallParticipant(@NonNull CallParticipant participant) {
boolean participantChanged = recipientId == null || !recipientId.equals(participant.getRecipient().getId());
recipientId = participant.getRecipient().getId();
@@ -140,9 +144,9 @@ public class CallParticipantView extends ConstraintLayout {
renderer.setVisibility(hasContentToRender ? View.VISIBLE : View.GONE);
if (participant.isVideoEnabled()) {
if (participant.getVideoSink().getEglBase() != null) {
renderer.init(participant.getVideoSink().getEglBase());
}
participant.getVideoSink().getLockableEglBase().performWithValidEglBase(eglBase -> {
renderer.init(eglBase);
});
renderer.attachBroadcastVideoSink(participant.getVideoSink());
} else {
renderer.attachBroadcastVideoSink(null);
@@ -192,6 +196,14 @@ public class CallParticipantView extends ConstraintLayout {
pipAvatar.setVisibility(shouldRenderInPip ? View.VISIBLE : View.GONE);
}
void hideAvatar() {
avatar.setAlpha(0f);
}
void showAvatar() {
avatar.setAlpha(1f);
}
void useLargeAvatar() {
changeAvatarParams(LARGE_AVATAR);
}

View File

@@ -34,6 +34,8 @@ public class CallParticipantsLayout extends FlexboxLayout {
private CallParticipant focusedParticipant = null;
private boolean shouldRenderInPip;
private boolean isPortrait;
private boolean isIncomingRing;
private LayoutStrategy layoutStrategy;
public CallParticipantsLayout(@NonNull Context context) {
super(context);
@@ -47,11 +49,21 @@ public class CallParticipantsLayout extends FlexboxLayout {
super(context, attrs, defStyleAttr);
}
void update(@NonNull List<CallParticipant> callParticipants, @NonNull CallParticipant focusedParticipant, boolean shouldRenderInPip, boolean isPortrait) {
void update(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean shouldRenderInPip,
boolean isPortrait,
boolean isIncomingRing,
@NonNull LayoutStrategy layoutStrategy)
{
this.callParticipants = callParticipants;
this.focusedParticipant = focusedParticipant;
this.shouldRenderInPip = shouldRenderInPip;
this.isPortrait = isPortrait;
this.isIncomingRing = isIncomingRing;
this.layoutStrategy = layoutStrategy;
setFlexDirection(layoutStrategy.getFlexDirection());
updateLayout();
}
@@ -107,11 +119,7 @@ public class CallParticipantsLayout extends FlexboxLayout {
callParticipantView.setCallParticipant(participant);
callParticipantView.setRenderInPip(shouldRenderInPip);
if (participant.isScreenSharing()) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
} else {
callParticipantView.setScalingType(isPortrait || count < 3 ? RendererCommon.ScalingType.SCALE_ASPECT_FILL : RendererCommon.ScalingType.SCALE_ASPECT_BALANCED);
}
layoutStrategy.setChildScaling(participant, callParticipantView, isPortrait, count);
if (count > 1) {
view.setPadding(MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING, MULTIPLE_PARTICIPANT_SPACING);
@@ -121,13 +129,19 @@ public class CallParticipantsLayout extends FlexboxLayout {
cardView.setRadius(0);
}
if (isIncomingRing) {
callParticipantView.hideAvatar();
} else {
callParticipantView.showAvatar();
}
if (count > 2) {
callParticipantView.useSmallAvatar();
} else {
callParticipantView.useLargeAvatar();
}
setChildLayoutParams(view, index, getChildCount());
layoutStrategy.setChildLayoutParams(view, index, getChildCount());
}
private void addCallParticipantView() {
@@ -139,17 +153,14 @@ public class CallParticipantsLayout extends FlexboxLayout {
addView(view);
}
private void setChildLayoutParams(@NonNull View child, int childPosition, int childCount) {
FlexboxLayout.LayoutParams params = (FlexboxLayout.LayoutParams) child.getLayoutParams();
if (childCount < 3) {
params.setFlexBasisPercent(1f);
} else {
if ((childCount % 2) != 0 && childPosition == childCount - 1) {
params.setFlexBasisPercent(1f);
} else {
params.setFlexBasisPercent(0.5f);
}
}
child.setLayoutParams(params);
public interface LayoutStrategy {
int getFlexDirection();
void setChildScaling(@NonNull CallParticipant callParticipant,
@NonNull CallParticipantView callParticipantView,
boolean isPortrait,
int childCount);
void setChildLayoutParams(@NonNull View child, int childPosition, int childCount);
}
}

View File

@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.components.webrtc
import android.view.View
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexboxLayout
import org.thoughtcrime.securesms.events.CallParticipant
import org.webrtc.RendererCommon
object CallParticipantsLayoutStrategies {
private object Portrait : CallParticipantsLayout.LayoutStrategy {
override fun getFlexDirection(): Int = FlexDirection.ROW
override fun setChildScaling(callParticipant: CallParticipant, callParticipantView: CallParticipantView, isPortrait: Boolean, childCount: Int) {
if (callParticipant.isScreenSharing) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
} else {
callParticipantView.setScalingType(if (isPortrait || childCount < 3) RendererCommon.ScalingType.SCALE_ASPECT_FILL else RendererCommon.ScalingType.SCALE_ASPECT_BALANCED)
}
}
override fun setChildLayoutParams(child: View, childPosition: Int, childCount: Int) {
val params = child.layoutParams as FlexboxLayout.LayoutParams
if (childCount < 3) {
params.flexBasisPercent = 1f
} else {
if ((childCount % 2) != 0 && childPosition == childCount - 1) {
params.flexBasisPercent = 1f
} else {
params.flexBasisPercent = 0.5f
}
}
child.layoutParams = params
}
}
private object Landscape : CallParticipantsLayout.LayoutStrategy {
override fun getFlexDirection() = FlexDirection.COLUMN
override fun setChildScaling(callParticipant: CallParticipant, callParticipantView: CallParticipantView, isPortrait: Boolean, childCount: Int) {
if (callParticipant.isScreenSharing) {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
} else {
callParticipantView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL, RendererCommon.ScalingType.SCALE_ASPECT_BALANCED)
}
}
override fun setChildLayoutParams(child: View, childPosition: Int, childCount: Int) {
val params = child.layoutParams as FlexboxLayout.LayoutParams
if (childCount < 4) {
params.flexBasisPercent = 1f
} else {
if ((childCount % 2) != 0 && childPosition == childCount - 1) {
params.flexBasisPercent = 1f
} else {
params.flexBasisPercent = 0.5f
}
}
child.layoutParams = params
}
}
@JvmStatic
fun getStrategy(isPortrait: Boolean, isLandscapeEnabled: Boolean): CallParticipantsLayout.LayoutStrategy {
return if (isPortrait || !isLandscapeEnabled) {
Portrait
} else {
Landscape
}
}
}

View File

@@ -36,7 +36,8 @@ public final class CallParticipantsState {
false,
false,
false,
OptionalLong.empty());
OptionalLong.empty(),
WebRtcControls.FoldableState.flat());
private final WebRtcViewModel.State callState;
private final WebRtcViewModel.GroupCallState groupCallState;
@@ -48,6 +49,7 @@ public final class CallParticipantsState {
private final boolean showVideoForOutgoing;
private final boolean isViewingFocusedParticipant;
private final OptionalLong remoteDevicesCount;
private final WebRtcControls.FoldableState foldableState;
public CallParticipantsState(@NonNull WebRtcViewModel.State callState,
@NonNull WebRtcViewModel.GroupCallState groupCallState,
@@ -58,7 +60,8 @@ public final class CallParticipantsState {
boolean isInPipMode,
boolean showVideoForOutgoing,
boolean isViewingFocusedParticipant,
OptionalLong remoteDevicesCount)
OptionalLong remoteDevicesCount,
@NonNull WebRtcControls.FoldableState foldableState)
{
this.callState = callState;
this.groupCallState = groupCallState;
@@ -70,6 +73,7 @@ public final class CallParticipantsState {
this.showVideoForOutgoing = showVideoForOutgoing;
this.isViewingFocusedParticipant = isViewingFocusedParticipant;
this.remoteDevicesCount = remoteDevicesCount;
this.foldableState = foldableState;
}
public @NonNull WebRtcViewModel.State getCallState() {
@@ -94,7 +98,10 @@ public final class CallParticipantsState {
listParticipants.addAll(remoteParticipants.getListParticipants());
}
listParticipants.add(CallParticipant.EMPTY);
if (foldableState.isFlat()) {
listParticipants.add(CallParticipant.EMPTY);
}
Collections.reverse(listParticipants);
return listParticipants;
@@ -155,6 +162,10 @@ public final class CallParticipantsState {
return localRenderState;
}
public boolean isFolded() {
return foldableState.isFolded();
}
public boolean isLargeVideoGroup() {
return getAllRemoteParticipants().size() > SMALL_GROUP_MAX;
}
@@ -186,6 +197,10 @@ public final class CallParticipantsState {
.or(() -> includeSelf ? OptionalLong.of(1L) : OptionalLong.empty());
}
public boolean isIncomingRing() {
return callState == WebRtcViewModel.State.CALL_INCOMING;
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState,
@NonNull WebRtcViewModel webRtcViewModel,
boolean enableVideo)
@@ -215,7 +230,8 @@ public final class CallParticipantsState {
oldState.isInPipMode,
newShowVideoForOutgoing,
oldState.isViewingFocusedParticipant,
webRtcViewModel.getRemoteDevicesCount());
webRtcViewModel.getRemoteDevicesCount(),
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, boolean isInPip) {
@@ -237,7 +253,8 @@ public final class CallParticipantsState {
isInPip,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount);
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState setExpanded(@NonNull CallParticipantsState oldState, boolean expanded) {
@@ -259,7 +276,8 @@ public final class CallParticipantsState {
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount);
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) {
@@ -281,7 +299,31 @@ public final class CallParticipantsState {
oldState.isInPipMode,
oldState.showVideoForOutgoing,
selectedPage == SelectedPage.FOCUSED,
oldState.remoteDevicesCount);
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull WebRtcControls.FoldableState foldableState) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount,
foldableState);
}
private static @NonNull WebRtcLocalRenderState determineLocalRenderMode(@NonNull CallParticipant localParticipant,

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components.webrtc
import android.opengl.EGL14
import org.signal.core.util.logging.Log
import org.webrtc.EglBase
import org.webrtc.EglBase10
import org.webrtc.EglBase14
import java.util.concurrent.locks.Lock
import java.util.concurrent.locks.ReentrantLock
import java.util.function.Consumer
import javax.microedition.khronos.egl.EGL10
import kotlin.concurrent.withLock
private val TAG = Log.tag(EglBaseWrapper::class.java)
/**
* Wrapper which allows caller to perform synchronized actions on an EglBase object.
*/
class EglBaseWrapper(val eglBase: EglBase?) {
private val lock: Lock = ReentrantLock()
fun require(): EglBase = requireNotNull(eglBase)
@Volatile
private var isReleased: Boolean = false
fun performWithValidEglBase(consumer: Consumer<EglBase>) {
if (isReleased) {
Log.d(TAG, "Tried to use a released EglBase", Exception())
return
}
if (eglBase == null) {
return
}
lock.withLock {
if (isReleased) {
Log.d(TAG, "Tried to use a released EglBase", Exception())
return
}
val hasSharedContext = when (val context: EglBase.Context = eglBase.eglBaseContext) {
is EglBase14.Context -> context.rawContext != EGL14.EGL_NO_CONTEXT
is EglBase10.Context -> context.rawContext != EGL10.EGL_NO_CONTEXT
else -> throw IllegalStateException("Unknown context")
}
if (hasSharedContext) {
consumer.accept(eglBase)
}
}
}
fun releaseEglBase() {
if (isReleased || eglBase == null) {
return
}
lock.withLock {
if (isReleased) {
return
}
isReleased = true
eglBase.release()
}
}
}

View File

@@ -116,14 +116,16 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu
}
public void clearVerticalBoundaries() {
setVerticalBoundaries(parent.getTop(), parent.getMeasuredHeight() + parent.getTop());
setTopVerticalBoundary(parent.getTop());
setBottomVerticalBoundary(parent.getMeasuredHeight() + parent.getTop());
}
public void setVerticalBoundaries(int topBoundary, int bottomBoundary) {
extraPaddingTop = topBoundary - parent.getTop();
extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary;
public void setTopVerticalBoundary(int topBoundary) {
extraPaddingTop = topBoundary - parent.getTop();
}
adjustPip();
public void setBottomVerticalBoundary(int bottomBoundary) {
extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary;
}
private boolean onGestureFinished(MotionEvent e) {

View File

@@ -187,8 +187,6 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf
setMeasuredDimension(size.x, size.y);
Log.d(TAG, "onMeasure(). New size: " + size.x + "x" + size.y);
if (attachedVideoSink != null) {
attachedVideoSink.putRequestingSize(this, size);
}

View File

@@ -15,33 +15,42 @@ class WebRtcCallParticipantsPage {
private final boolean isSpeaker;
private final boolean isRenderInPip;
private final boolean isPortrait;
private final boolean isLandscapeEnabled;
private final boolean isIncomingRing;
static WebRtcCallParticipantsPage forMultipleParticipants(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean isRenderInPip,
boolean isPortrait)
boolean isPortrait,
boolean isLandscapeEnabled,
boolean isIncomingRing)
{
return new WebRtcCallParticipantsPage(callParticipants, focusedParticipant, false, isRenderInPip, isPortrait);
return new WebRtcCallParticipantsPage(callParticipants, focusedParticipant, false, isRenderInPip, isPortrait, isLandscapeEnabled, isIncomingRing);
}
static WebRtcCallParticipantsPage forSingleParticipant(@NonNull CallParticipant singleParticipant,
boolean isRenderInPip,
boolean isPortrait)
boolean isPortrait,
boolean isLandscapeEnabled)
{
return new WebRtcCallParticipantsPage(Collections.singletonList(singleParticipant), singleParticipant, true, isRenderInPip, isPortrait);
return new WebRtcCallParticipantsPage(Collections.singletonList(singleParticipant), singleParticipant, true, isRenderInPip, isPortrait, isLandscapeEnabled, false);
}
private WebRtcCallParticipantsPage(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean isSpeaker,
boolean isRenderInPip,
boolean isPortrait)
boolean isPortrait,
boolean isLandscapeEnabled,
boolean isIncomingRing)
{
this.callParticipants = callParticipants;
this.focusedParticipant = focusedParticipant;
this.isSpeaker = isSpeaker;
this.isRenderInPip = isRenderInPip;
this.isPortrait = isPortrait;
this.isLandscapeEnabled = isLandscapeEnabled;
this.isIncomingRing = isIncomingRing;
}
public @NonNull List<CallParticipant> getCallParticipants() {
@@ -64,6 +73,14 @@ class WebRtcCallParticipantsPage {
return isPortrait;
}
public boolean isIncomingRing() {
return isIncomingRing;
}
public @NonNull CallParticipantsLayout.LayoutStrategy getLayoutStrategy() {
return CallParticipantsLayoutStrategies.getStrategy(isPortrait, isLandscapeEnabled);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@@ -86,7 +86,7 @@ class WebRtcCallParticipantsPagerAdapter extends ListAdapter<WebRtcCallParticipa
@Override
void bind(WebRtcCallParticipantsPage page) {
callParticipantsLayout.update(page.getCallParticipants(), page.getFocusedParticipant(), page.isRenderInPip(), page.isPortrait());
callParticipantsLayout.update(page.getCallParticipants(), page.getFocusedParticipant(), page.isRenderInPip(), page.isPortrait(), page.isIncomingRing(), page.getLayoutStrategy());
}
}

View File

@@ -3,9 +3,9 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
@@ -16,7 +16,6 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.Guideline;
@@ -32,11 +31,12 @@ import androidx.viewpager2.widget.ViewPager2;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.google.android.material.button.MaterialButton;
import com.google.common.collect.Sets;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.ResizeAnimation;
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
@@ -47,8 +47,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.util.BlurTransformation;
import org.thoughtcrime.securesms.util.SetUtil;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
import org.webrtc.RendererCommon;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
@@ -57,9 +59,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
public class WebRtcCallView extends FrameLayout {
public class WebRtcCallView extends ConstraintLayout {
private static final long TRANSITION_DURATION_MILLIS = 250;
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
@@ -70,8 +70,11 @@ public class WebRtcCallView extends FrameLayout {
public static final int CONTROLS_HEIGHT = 98;
private WebRtcAudioOutputToggleButton audioToggle;
private TextView audioToggleLabel;
private AccessibleToggleButton videoToggle;
private TextView videoToggleLabel;
private AccessibleToggleButton micToggle;
private TextView micToggleLabel;
private ViewGroup smallLocalRenderFrame;
private CallParticipantView smallLocalRender;
private View largeLocalRenderFrame;
@@ -80,42 +83,56 @@ public class WebRtcCallView extends FrameLayout {
private ImageView largeLocalRenderNoVideoAvatar;
private TextView recipientName;
private TextView status;
private TextView incomingRingStatus;
private ConstraintLayout parent;
private ConstraintLayout participantsParent;
private ControlsListener controlsListener;
private RecipientId recipientId;
private ImageView answer;
private ImageView cameraDirectionToggle;
private TextView cameraDirectionToggleLabel;
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private ImageView hangup;
private TextView hangupLabel;
private View answerWithAudio;
private View answerWithAudioLabel;
private View topGradient;
private View footerGradient;
private View startCallControls;
private ViewPager2 callParticipantsPager;
private RecyclerView callParticipantsRecycler;
private Toolbar toolbar;
private ConstraintLayout toolbar;
private MaterialButton startCall;
private TextView participantCount;
private Stub<FrameLayout> groupCallSpeakerHint;
private Stub<View> groupCallFullStub;
private View errorButton;
private int pagerBottomMarginDp;
private boolean controlsVisible = true;
private Guideline showParticipantsGuideline;
private Guideline topFoldGuideline;
private Guideline callScreenTopFoldGuideline;
private View foldParticipantCountWrapper;
private TextView foldParticipantCount;
private AvatarImageView largeHeaderAvatar;
private ConstraintSet smallHeaderConstraints;
private Guideline statusBarGuideline;
private View fullScreenShade;
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
private PictureInPictureExpansionHelper pictureInPictureExpansionHelper;
private final Set<View> incomingCallViews = new HashSet<>();
private final Set<View> topViews = new HashSet<>();
private final Set<View> visibleViewSet = new HashSet<>();
private final Set<View> allTimeVisibleViews = new HashSet<>();
private final Set<View> adjustableMarginsSet = new HashSet<>();
private final Set<View> rotatableControls = new HashSet<>();
private WebRtcControls controls = WebRtcControls.NONE;
private final Runnable fadeOutRunnable = () -> {
private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS);
private WebRtcControls controls = WebRtcControls.NONE;
private final Runnable fadeOutRunnable = () -> {
if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls();
};
@@ -135,8 +152,11 @@ public class WebRtcCallView extends FrameLayout {
super.onFinishInflate();
audioToggle = findViewById(R.id.call_screen_speaker_toggle);
audioToggleLabel = findViewById(R.id.call_screen_speaker_toggle_label);
videoToggle = findViewById(R.id.call_screen_video_toggle);
videoToggleLabel = findViewById(R.id.call_screen_video_toggle_label);
micToggle = findViewById(R.id.call_screen_audio_mic_toggle);
micToggleLabel = findViewById(R.id.call_screen_audio_mic_toggle_label);
smallLocalRenderFrame = findViewById(R.id.call_screen_pip);
smallLocalRender = findViewById(R.id.call_screen_small_local_renderer);
largeLocalRenderFrame = findViewById(R.id.call_screen_large_local_renderer_frame);
@@ -145,28 +165,38 @@ public class WebRtcCallView extends FrameLayout {
largeLocalRenderNoVideoAvatar = findViewById(R.id.call_screen_large_local_video_off_avatar);
recipientName = findViewById(R.id.call_screen_recipient_name);
status = findViewById(R.id.call_screen_status);
incomingRingStatus = findViewById(R.id.call_screen_incoming_ring_status);
parent = findViewById(R.id.call_screen);
participantsParent = findViewById(R.id.call_screen_participants_parent);
answer = findViewById(R.id.call_screen_answer_call);
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
cameraDirectionToggleLabel = findViewById(R.id.call_screen_camera_direction_toggle_label);
hangup = findViewById(R.id.call_screen_end_call);
hangupLabel = findViewById(R.id.call_screen_end_call_label);
answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label);
topGradient = findViewById(R.id.call_screen_header_gradient);
footerGradient = findViewById(R.id.call_screen_footer_gradient);
startCallControls = findViewById(R.id.call_screen_start_call_controls);
callParticipantsPager = findViewById(R.id.call_screen_participants_pager);
callParticipantsRecycler = findViewById(R.id.call_screen_participants_recycler);
toolbar = findViewById(R.id.call_screen_toolbar);
toolbar = findViewById(R.id.call_screen_header);
startCall = findViewById(R.id.call_screen_start_call_start_call);
errorButton = findViewById(R.id.call_screen_error_cancel);
groupCallSpeakerHint = new Stub<>(findViewById(R.id.call_screen_group_call_speaker_hint));
groupCallFullStub = new Stub<>(findViewById(R.id.group_call_call_full_view));
showParticipantsGuideline = findViewById(R.id.call_screen_show_participants_guideline);
topFoldGuideline = findViewById(R.id.fold_top_guideline);
callScreenTopFoldGuideline = findViewById(R.id.fold_top_call_screen_guideline);
foldParticipantCountWrapper = findViewById(R.id.fold_show_participants_menu_counter_wrapper);
foldParticipantCount = findViewById(R.id.fold_show_participants_menu_counter);
largeHeaderAvatar = findViewById(R.id.call_screen_header_avatar);
statusBarGuideline = findViewById(R.id.call_screen_status_bar_guideline);
fullScreenShade = findViewById(R.id.call_screen_full_shade);
View topGradient = findViewById(R.id.call_screen_header_gradient);
View decline = findViewById(R.id.call_screen_decline_call);
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
View declineLabel = findViewById(R.id.call_screen_decline_call_label);
View cancelStartCall = findViewById(R.id.call_screen_start_call_cancel);
callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4)));
@@ -232,7 +262,6 @@ public class WebRtcCallView extends FrameLayout {
controlsListener.onStartCall(videoToggle.isChecked());
}
});
cancelStartCall.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCancelStartCall));
ColorMatrix greyScaleMatrix = new ColorMatrix();
greyScaleMatrix.setSaturation(0);
@@ -254,6 +283,9 @@ public class WebRtcCallView extends FrameLayout {
rotatableControls.add(cameraDirectionToggle);
rotatableControls.add(decline);
rotatableControls.add(smallLocalRender.findViewById(R.id.call_participant_mic_muted));
smallHeaderConstraints = new ConstraintSet();
smallHeaderConstraints.clone(getContext(), R.layout.webrtc_call_view_header_small);
}
@Override
@@ -267,7 +299,6 @@ public class WebRtcCallView extends FrameLayout {
@Override
protected boolean fitSystemWindows(Rect insets) {
Guideline statusBarGuideline = findViewById(R.id.call_screen_status_bar_guideline);
Guideline navigationBarGuideline = findViewById(R.id.call_screen_navigation_bar_guideline);
statusBarGuideline.setGuidelineBegin(insets.top);
@@ -279,10 +310,18 @@ public class WebRtcCallView extends FrameLayout {
@Override
public void onWindowSystemUiVisibilityChanged(int visible) {
if ((visible & SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) {
pictureInPictureGestureHelper.setVerticalBoundaries(toolbar.getBottom(), videoToggle.getTop());
if (controls.adjustForFold()) {
pictureInPictureGestureHelper.clearVerticalBoundaries();
pictureInPictureGestureHelper.setTopVerticalBoundary(toolbar.getTop());
} else {
pictureInPictureGestureHelper.setTopVerticalBoundary(toolbar.getBottom());
pictureInPictureGestureHelper.setBottomVerticalBoundary(videoToggle.getTop());
}
} else {
pictureInPictureGestureHelper.clearVerticalBoundaries();
}
pictureInPictureGestureHelper.adjustPip();
}
@Override
@@ -305,49 +344,61 @@ public class WebRtcCallView extends FrameLayout {
micToggle.setChecked(isMicEnabled, false);
}
public void updateCallParticipants(@NonNull CallParticipantsState state, boolean isPortrait) {
List<WebRtcCallParticipantsPage> pages = new ArrayList<>(2);
public void updateCallParticipants(@NonNull CallParticipantsViewState callParticipantsViewState) {
CallParticipantsState state = callParticipantsViewState.getCallParticipantsState();
boolean isPortrait = callParticipantsViewState.isPortrait();
boolean isLandscapeEnabled = callParticipantsViewState.isLandscapeEnabled();
List<WebRtcCallParticipantsPage> pages = new ArrayList<>(2);
if (!state.getGridParticipants().isEmpty()) {
pages.add(WebRtcCallParticipantsPage.forMultipleParticipants(state.getGridParticipants(), state.getFocusedParticipant(), state.isInPipMode(), isPortrait));
pages.add(WebRtcCallParticipantsPage.forMultipleParticipants(state.getGridParticipants(), state.getFocusedParticipant(), state.isInPipMode(), isPortrait, isLandscapeEnabled, state.isIncomingRing()));
}
if (state.getFocusedParticipant() != CallParticipant.EMPTY && state.getAllRemoteParticipants().size() > 1) {
pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode(), isPortrait));
pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode(), isPortrait, isLandscapeEnabled));
}
if ((state.getGroupCallState().isNotIdle() && state.getRemoteDevicesCount().orElse(0) > 0) || state.getGroupCallState().isConnected()) {
recipientName.setText(state.getRemoteParticipantsDescription(getContext()));
} else if (state.getGroupCallState().isNotIdle()) {
recipientName.setText(getContext().getString(R.string.WebRtcCallView__s_group_call, Recipient.resolved(recipientId).getDisplayName(getContext())));
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN && state.getGroupCallState().isNotIdle()) {
status.setText(state.getRemoteParticipantsDescription(getContext()));
}
if (state.getGroupCallState().isNotIdle() && participantCount != null) {
participantCount.setText(state.getParticipantCount()
.mapToObj(String::valueOf).orElse("\u2014"));
participantCount.setEnabled(state.getParticipantCount().isPresent());
if (state.getGroupCallState().isNotIdle()) {
String text = state.getParticipantCount()
.mapToObj(String::valueOf).orElse("\u2014");
boolean enabled = state.getParticipantCount().isPresent();
foldParticipantCount.setText(text);
foldParticipantCount.setEnabled(enabled);
}
pagerAdapter.submitList(pages);
recyclerAdapter.submitList(state.getListParticipants());
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), state.getFocusedParticipant());
if (state.isLargeVideoGroup() && !state.isInPipMode()) {
boolean displaySmallSelfPipInLandscape = !isPortrait && isLandscapeEnabled;
updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant(), state.getFocusedParticipant(), displaySmallSelfPipInLandscape);
if (state.isLargeVideoGroup() && !state.isInPipMode() && !state.isFolded()) {
layoutParticipantsForLargeCount();
} else {
layoutParticipantsForSmallCount();
}
}
public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, @NonNull CallParticipant localCallParticipant, @NonNull CallParticipant focusedParticipant) {
public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state,
@NonNull CallParticipant localCallParticipant,
@NonNull CallParticipant focusedParticipant,
boolean displaySmallSelfPipInLandscape)
{
largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT);
smallLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
largeLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
if (localCallParticipant.getVideoSink().getEglBase() != null) {
largeLocalRender.init(localCallParticipant.getVideoSink().getEglBase());
}
localCallParticipant.getVideoSink().getLockableEglBase().performWithValidEglBase(eglBase -> {
largeLocalRender.init(eglBase);
});
videoToggle.setChecked(localCallParticipant.isVideoEnabled(), false);
smallLocalRender.setRenderInPip(true);
@@ -372,7 +423,7 @@ public class WebRtcCallView extends FrameLayout {
break;
case SMALL_RECTANGLE:
smallLocalRenderFrame.setVisibility(View.VISIBLE);
animatePipToLargeRectangle();
animatePipToLargeRectangle(displaySmallSelfPipInLandscape);
largeLocalRender.attachBroadcastVideoSink(null);
largeLocalRenderFrame.setVisibility(View.GONE);
@@ -418,18 +469,13 @@ public class WebRtcCallView extends FrameLayout {
recipientId = recipient.getId();
largeHeaderAvatar.setRecipient(recipient, false);
if (recipient.isGroup()) {
if (toolbar.getMenu().findItem(R.id.menu_group_call_participants_list) == null) {
toolbar.inflateMenu(R.menu.group_call);
View showParticipants = toolbar.getMenu().findItem(R.id.menu_group_call_participants_list).getActionView();
showParticipants.setOnClickListener(unused -> showParticipantsList());
participantCount = showParticipants.findViewById(R.id.show_participants_menu_counter);
}
} else {
recipientName.setText(recipient.getDisplayName(getContext()));
foldParticipantCountWrapper.setOnClickListener(unused -> showParticipantsList());
}
recipientName.setText(recipient.getDisplayName(getContext()));
}
public void setStatus(@NonNull String status) {
@@ -480,6 +526,23 @@ public class WebRtcCallView extends FrameLayout {
visibleViewSet.clear();
if (webRtcControls.adjustForFold()) {
showParticipantsGuideline.setGuidelineBegin(-1);
showParticipantsGuideline.setGuidelineEnd(webRtcControls.getFold());
topFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
callScreenTopFoldGuideline.setGuidelineEnd(webRtcControls.getFold());
} else {
showParticipantsGuideline.setGuidelineBegin(((LayoutParams) statusBarGuideline.getLayoutParams()).guideBegin);
showParticipantsGuideline.setGuidelineEnd(-1);
topFoldGuideline.setGuidelineEnd(0);
callScreenTopFoldGuideline.setGuidelineEnd(0);
}
if (webRtcControls.displayGroupMembersButton()) {
visibleViewSet.add(foldParticipantCountWrapper);
foldParticipantCount.setClickable(webRtcControls.adjustForFold());
}
if (webRtcControls.displayStartCallControls()) {
visibleViewSet.add(footerGradient);
visibleViewSet.add(startCallControls);
@@ -500,12 +563,6 @@ public class WebRtcCallView extends FrameLayout {
groupCallFullStub.get().setVisibility(View.GONE);
}
MenuItem item = toolbar.getMenu().findItem(R.id.menu_group_call_participants_list);
if (item != null) {
item.setVisible(webRtcControls.displayGroupMembersButton());
item.setEnabled(webRtcControls.displayGroupMembersButton());
}
if (webRtcControls.displayTopViews()) {
visibleViewSet.addAll(topViews);
}
@@ -513,7 +570,9 @@ public class WebRtcCallView extends FrameLayout {
if (webRtcControls.displayIncomingCallButtons()) {
visibleViewSet.addAll(incomingCallViews);
status.setText(R.string.WebRtcCallView__signal_voice_call);
incomingRingStatus.setVisibility(VISIBLE);
incomingRingStatus.setText(R.string.WebRtcCallView__signal_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
}
@@ -521,12 +580,19 @@ public class WebRtcCallView extends FrameLayout {
visibleViewSet.add(answerWithAudio);
visibleViewSet.add(answerWithAudioLabel);
status.setText(R.string.WebRtcCallView__signal_video_call);
incomingRingStatus.setVisibility(VISIBLE);
incomingRingStatus.setText(R.string.WebRtcCallView__signal_video_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
}
if (!webRtcControls.displayIncomingCallButtons() && !webRtcControls.displayAnswerWithAudio()){
incomingRingStatus.setVisibility(GONE);
}
if (webRtcControls.displayAudioToggle()) {
visibleViewSet.add(audioToggle);
visibleViewSet.add(audioToggleLabel);
audioToggle.setControlAvailability(webRtcControls.enableHandsetInAudioToggle(),
webRtcControls.enableHeadsetInAudioToggle());
@@ -536,19 +602,23 @@ public class WebRtcCallView extends FrameLayout {
if (webRtcControls.displayCameraToggle()) {
visibleViewSet.add(cameraDirectionToggle);
visibleViewSet.add(cameraDirectionToggleLabel);
}
if (webRtcControls.displayEndCall()) {
visibleViewSet.add(hangup);
visibleViewSet.add(hangupLabel);
visibleViewSet.add(footerGradient);
}
if (webRtcControls.displayMuteAudio()) {
visibleViewSet.add(micToggle);
visibleViewSet.add(micToggleLabel);
}
if (webRtcControls.displayVideoToggle()) {
visibleViewSet.add(videoToggle);
visibleViewSet.add(videoToggleLabel);
}
if (webRtcControls.displaySmallOngoingCallButtons()) {
@@ -563,6 +633,14 @@ public class WebRtcCallView extends FrameLayout {
callParticipantsRecycler.setVisibility(View.GONE);
}
if (webRtcControls.showFullScreenShade()) {
fullScreenShade.setVisibility(VISIBLE);
visibleViewSet.remove(topGradient);
visibleViewSet.remove(footerGradient);
} else {
fullScreenShade.setVisibility(GONE);
}
if (webRtcControls.isFadeOutEnabled()) {
if (!controls.isFadeOutEnabled()) {
scheduleFadeOut();
@@ -575,12 +653,33 @@ public class WebRtcCallView extends FrameLayout {
}
}
if (webRtcControls.adjustForFold() && webRtcControls.isFadeOutEnabled() && !controls.adjustForFold()) {
scheduleFadeOut();
}
boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold();
controls = webRtcControls;
if (!visibleViewSet.equals(lastVisibleSet) || !controls.isFadeOutEnabled()) {
fadeInNewUiState(lastVisibleSet, webRtcControls.displaySmallOngoingCallButtons());
post(() -> pictureInPictureGestureHelper.setVerticalBoundaries(toolbar.getBottom(), videoToggle.getTop()));
if (!controls.isFadeOutEnabled()) {
controlsVisible = true;
}
allTimeVisibleViews.addAll(visibleViewSet);
if (!visibleViewSet.equals(lastVisibleSet) ||
!controls.isFadeOutEnabled() ||
(webRtcControls.showSmallHeader() && largeHeaderAvatar.getVisibility() == View.VISIBLE) ||
forceUpdate)
{
if (controlsListener != null) {
controlsListener.showSystemUI();
}
throttledDebouncer.publish(() -> fadeInNewUiState(webRtcControls.displaySmallOngoingCallButtons(), webRtcControls.showSmallHeader()));
}
onWindowSystemUiVisibilityChanged(getWindowSystemUiVisibility());
}
public @NonNull View getVideoTooltipTarget() {
@@ -653,8 +752,15 @@ public class WebRtcCallView extends FrameLayout {
});
}
private void animatePipToLargeRectangle() {
ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, ViewUtil.dpToPx(90), ViewUtil.dpToPx(160));
private void animatePipToLargeRectangle(boolean isLandscape) {
final Point dimens;
if (isLandscape) {
dimens = new Point(ViewUtil.dpToPx(160), ViewUtil.dpToPx(90));
} else {
dimens = new Point(ViewUtil.dpToPx(90), ViewUtil.dpToPx(160));
}
ResizeAnimation animation = new ResizeAnimation(smallLocalRenderFrame, dimens.x, dimens.y);
animation.setDuration(PIP_RESIZE_DURATION);
animation.setAnimationListener(new SimpleAnimationListener() {
@Override
@@ -720,7 +826,7 @@ public class WebRtcCallView extends FrameLayout {
return 0;
}
return controlsVisible ? margin + CONTROLS_HEIGHT : margin;
return (controlsVisible || controls.adjustForFold()) ? margin + CONTROLS_HEIGHT : margin;
}
private void layoutParticipants() {
@@ -756,25 +862,34 @@ public class WebRtcCallView extends FrameLayout {
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(parent);
for (View view : visibleViewSet) {
for (View view : controlsToFade()) {
constraintSet.setVisibility(view.getId(), visibility);
}
adjustParticipantsRecycler(constraintSet);
constraintSet.applyTo(parent);
layoutParticipants();
}
private void fadeInNewUiState(@NonNull Set<View> previouslyVisibleViewSet, boolean useSmallMargins) {
private Set<View> controlsToFade() {
if (controls.adjustForFold()) {
return Sets.intersection(topViews, visibleViewSet);
} else {
return visibleViewSet;
}
}
private void fadeInNewUiState(boolean useSmallMargins, boolean showSmallHeader) {
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
TransitionManager.endTransitions(parent);
TransitionManager.beginDelayedTransition(parent, transition);
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(parent);
for (View view : SetUtil.difference(previouslyVisibleViewSet, visibleViewSet)) {
for (View view : SetUtil.difference(allTimeVisibleViews, visibleViewSet)) {
constraintSet.setVisibility(view.getId(), ConstraintSet.GONE);
}
@@ -789,7 +904,23 @@ public class WebRtcCallView extends FrameLayout {
}
}
adjustParticipantsRecycler(constraintSet);
constraintSet.applyTo(parent);
if (showSmallHeader) {
smallHeaderConstraints.applyTo(toolbar);
}
}
private void adjustParticipantsRecycler(@NonNull ConstraintSet constraintSet) {
if (controlsVisible || controls.adjustForFold()) {
constraintSet.connect(R.id.call_screen_participants_recycler, ConstraintSet.BOTTOM, R.id.call_screen_video_toggle, ConstraintSet.TOP);
} else {
constraintSet.connect(R.id.call_screen_participants_recycler, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
}
constraintSet.setHorizontalBias(R.id.call_screen_participants_recycler, controls.adjustForFold() ? 0.5f : 1f);
}
private void scheduleFadeOut() {

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.os.Handler;
import android.os.Looper;
import android.util.Pair;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
@@ -14,6 +15,7 @@ import androidx.lifecycle.ViewModelProvider;
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;
@@ -41,7 +43,9 @@ public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls);
private final MutableLiveData<WebRtcControls.FoldableState> foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<Event>();
private final MutableLiveData<Long> elapsed = new MutableLiveData<>(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
@@ -53,6 +57,8 @@ public class WebRtcCallViewModel extends ViewModel {
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembers = LiveDataUtil.skip(Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers())), 1);
private final LiveData<Boolean> shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint);
private final LiveData<Orientation> orientation;
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final LiveData<Integer> controlsRotation;
private boolean canDisplayTooltipIfNeeded = true;
private boolean hasEnabledLocalVideo = false;
@@ -69,13 +75,24 @@ public class WebRtcCallViewModel extends ViewModel {
private final WebRtcCallRepository repository = new WebRtcCallRepository(ApplicationDependencies.getApplication());
private WebRtcCallViewModel(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) {
orientation = deviceOrientationMonitor.getOrientation();
orientation = deviceOrientationMonitor.getOrientation();
controlsRotation = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(isLandscapeEnabled),
Transformations.distinctUntilChanged(orientation),
this::resolveRotation);
}
public LiveData<Integer> getControlsRotation() {
return controlsRotation;
}
public LiveData<Orientation> getOrientation() {
return Transformations.distinctUntilChanged(orientation);
}
public LiveData<Pair<Orientation, Boolean>> getOrientationAndLandscapeEnabled() {
return LiveDataUtil.combineLatest(orientation, isLandscapeEnabled, Pair::new);
}
public LiveData<Boolean> getMicrophoneEnabled() {
return Transformations.distinctUntilChanged(microphoneEnabled);
}
@@ -92,6 +109,12 @@ public class WebRtcCallViewModel extends ViewModel {
liveRecipient.setValue(recipient.live());
}
public void setFoldableState(@NonNull WebRtcControls.FoldableState foldableState) {
this.foldableState.postValue(foldableState);
ThreadUtil.runOnMain(() -> participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), foldableState)));
}
public LiveData<Event> getEvents() {
return events;
}
@@ -140,6 +163,10 @@ public class WebRtcCallViewModel extends ViewModel {
participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), isInPipMode));
}
public void setIsLandscapeEnabled(boolean isLandscapeEnabled) {
this.isLandscapeEnabled.postValue(isLandscapeEnabled);
}
@MainThread
public void setIsViewingFocusedParticipant(@NonNull CallParticipantsState.SelectedPage page) {
if (page == CallParticipantsState.SelectedPage.FOCUSED) {
@@ -239,6 +266,23 @@ public class WebRtcCallViewModel extends ViewModel {
}
}
private int resolveRotation(boolean isLandscapeEnabled, @NonNull Orientation orientation) {
if (isLandscapeEnabled) {
return 0;
}
switch (orientation) {
case LANDSCAPE_LEFT_EDGE:
return 90;
case LANDSCAPE_RIGHT_EDGE:
return -90;
case PORTRAIT_BOTTOM_EDGE:
return 0;
default:
throw new AssertionError();
}
}
private boolean containsPlaceholders(@NonNull List<CallParticipant> callParticipants) {
return Stream.of(callParticipants).anyMatch(p -> p.getCallParticipantId().getDemuxId() == CallParticipantId.DEFAULT_ID);
}
@@ -314,7 +358,12 @@ public class WebRtcCallViewModel extends ViewModel {
callState,
groupCallState,
audioOutput,
participantLimit));
participantLimit,
WebRtcControls.FoldableState.flat()));
}
private @NonNull WebRtcControls updateControlsFoldableState(@NonNull WebRtcControls.FoldableState foldableState, @NonNull WebRtcControls controls) {
return controls.withFoldableState(foldableState);
}
private @NonNull WebRtcControls getRealWebRtcControls(boolean isInPipMode, @NonNull WebRtcControls controls) {

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
@@ -11,7 +12,7 @@ import org.thoughtcrime.securesms.R;
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);
public static final WebRtcControls PIP = new WebRtcControls(false, false, false, false, true, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat());
private final boolean isRemoteVideoEnabled;
private final boolean isLocalVideoEnabled;
@@ -23,9 +24,10 @@ public final class WebRtcControls {
private final GroupCallState groupCallState;
private final WebRtcAudioOutput audioOutput;
private final Long participantLimit;
private final FoldableState foldableState;
private WebRtcControls() {
this(false, false, false, false, false, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null);
this(false, false, false, false, false, false, CallState.NONE, GroupCallState.NONE, WebRtcAudioOutput.HANDSET, null, FoldableState.flat());
}
WebRtcControls(boolean isLocalVideoEnabled,
@@ -37,7 +39,8 @@ public final class WebRtcControls {
@NonNull CallState callState,
@NonNull GroupCallState groupCallState,
@NonNull WebRtcAudioOutput audioOutput,
@Nullable Long participantLimit)
@Nullable Long participantLimit,
@NonNull FoldableState foldableState)
{
this.isLocalVideoEnabled = isLocalVideoEnabled;
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
@@ -49,6 +52,21 @@ public final class WebRtcControls {
this.groupCallState = groupCallState;
this.audioOutput = audioOutput;
this.participantLimit = participantLimit;
this.foldableState = foldableState;
}
public @NonNull WebRtcControls withFoldableState(FoldableState foldableState) {
return new WebRtcControls(isLocalVideoEnabled,
isRemoteVideoEnabled,
isMoreThanOneCameraAvailable,
isBluetoothAvailable,
isInPipMode,
hasAtLeastOneRemote,
callState,
groupCallState,
audioOutput,
participantLimit,
foldableState);
}
boolean displayErrorControls() {
@@ -59,6 +77,14 @@ public final class WebRtcControls {
return isPreJoin();
}
boolean adjustForFold() {
return foldableState.isFolded();
}
@Px int getFold() {
return foldableState.getFoldPoint();
}
@StringRes int getStartCallButtonText() {
if (isGroupCall()) {
if (groupCallState == GroupCallState.FULL) {
@@ -86,7 +112,7 @@ public final class WebRtcControls {
}
boolean displayGroupMembersButton() {
return groupCallState.isAtLeast(GroupCallState.CONNECTING);
return (groupCallState.isAtLeast(GroupCallState.CONNECTING) && hasAtLeastOneRemote) || groupCallState.isAtLeast(GroupCallState.FULL);
}
boolean displayEndCall() {
@@ -149,6 +175,14 @@ public final class WebRtcControls {
return audioOutput;
}
boolean showSmallHeader() {
return isAtLeastOutgoing();
}
boolean showFullScreenShade() {
return isPreJoin() || isIncoming();
}
private boolean isError() {
return callState == CallState.ERROR;
}
@@ -199,4 +233,35 @@ public final class WebRtcControls {
return compareTo(other) >= 0;
}
}
public static final class FoldableState {
private static final int NOT_SET = -1;
private final int foldPoint;
public FoldableState(int foldPoint) {
this.foldPoint = foldPoint;
}
public boolean isFolded() {
return foldPoint != NOT_SET;
}
public boolean isFlat() {
return foldPoint == NOT_SET;
}
public int getFoldPoint() {
return foldPoint;
}
public static @NonNull FoldableState folded(int foldPoint) {
return new FoldableState(foldPoint);
}
public static @NonNull FoldableState flat() {
return new FoldableState(NOT_SET);
}
}
}

View File

@@ -10,12 +10,14 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
@@ -24,7 +26,7 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.whispersystems.libsignal.util.guava.Optional;
public class ContactSelectionListItem extends LinearLayout implements RecipientForeverObserver {
public class ContactSelectionListItem extends ConstraintLayout implements RecipientForeverObserver {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListItem.class);
@@ -90,17 +92,23 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
} else if (recipientId != null) {
this.recipient = Recipient.live(recipientId);
this.recipient.observeForever(this);
name = this.recipient.get().getDisplayName(getContext());
}
if (recipient == null || recipient.get().isRegistered()) {
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
if (recipientSnapshot != null && !recipientSnapshot.isResolving()) {
contactName = recipientSnapshot.getDisplayName(getContext());
name = contactName;
} else if (recipient != null) {
name = "";
}
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered()) {
smsTag.setVisibility(GONE);
} else {
smsTag.setVisibility(VISIBLE);
}
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
if (recipientSnapshot == null || recipientSnapshot.isResolving()) {
this.contactPhotoImage.setAvatar(glideRequests, null, false);
setText(null, type, name, number, label, about);
@@ -207,8 +215,20 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
if (this.recipient != null && this.recipient.getId().equals(recipient.getId())) {
contactName = recipient.getDisplayName(getContext());
contactAbout = recipient.getCombinedAboutAndEmoji();
if (recipient.isGroup() && recipient.getGroupId().isPresent()) {
contactNumber = recipient.getGroupId().get().toString();
} else if (recipient.hasE164()) {
contactNumber = PhoneNumberFormatter.prettyPrint(recipient.getE164().or(""));
} else {
contactNumber = recipient.getEmail().or("");
}
contactPhotoImage.setAvatar(glideRequests, recipient, false);
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
} 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

@@ -3,11 +3,15 @@ package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
public interface FallbackContactPhoto {
public Drawable asDrawable(Context context, int color);
public Drawable asDrawable(Context context, int color, boolean inverted);
public Drawable asSmallDrawable(Context context, int color, boolean inverted);
public Drawable asCallCard(Context context);
Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color);
Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted);
Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted);
Drawable asCallCard(@NonNull Context context);
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Px;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
/**
* Fallback resource based contact photo with a 20dp icon
*/
public final class FallbackPhoto implements FallbackContactPhoto {
@DrawableRes private final int drawableResource;
@Px private final int foregroundInset;
public FallbackPhoto(@DrawableRes int drawableResource, @Px int foregroundInset) {
this.drawableResource = drawableResource;
this.foregroundInset = foregroundInset;
}
@Override
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return buildDrawable(context, color);
}
@Override
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asCallCard(@NonNull Context context) {
throw new UnsupportedOperationException();
}
private @NonNull Drawable buildDrawable(@NonNull Context context, @NonNull AvatarColor color) {
Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate();
Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawableResource));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
DrawableCompat.setTint(background, color.colorInt());
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);
return drawable;
}
}

View File

@@ -10,6 +10,8 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
@@ -26,33 +28,33 @@ public final class FallbackPhoto20dp implements FallbackContactPhoto {
}
@Override
public Drawable asDrawable(Context context, int color) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return buildDrawable(context, color);
}
@Override
public Drawable asDrawable(Context context, int color, boolean inverted) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asCallCard(Context context) {
public Drawable asCallCard(@NonNull Context context) {
throw new UnsupportedOperationException();
}
private @NonNull Drawable buildDrawable(@NonNull Context context, int color) {
private @NonNull Drawable buildDrawable(@NonNull Context context, @NonNull AvatarColor color) {
Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate();
Drawable foreground = AppCompatResources.getDrawable(context, drawable20dp);
Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient);
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable20dp));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
int foregroundInset = ViewUtil.dpToPx(2);
DrawableCompat.setTint(background, color);
DrawableCompat.setTint(background, color.colorInt());
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);

View File

@@ -1,57 +1,55 @@
package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.LayerDrawable;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
public final class FallbackPhoto80dp implements FallbackContactPhoto {
@DrawableRes private final int drawable80dp;
private final int backgroundColor;
@DrawableRes private final int drawable80dp;
private final AvatarColor color;
public FallbackPhoto80dp(@DrawableRes int drawable80dp, int backgroundColor) {
this.drawable80dp = drawable80dp;
this.backgroundColor = backgroundColor;
public FallbackPhoto80dp(@DrawableRes int drawable80dp, @NonNull AvatarColor color) {
this.drawable80dp = drawable80dp;
this.color = color;
}
@Override
public Drawable asDrawable(Context context, int color) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return buildDrawable(context);
}
@Override
public Drawable asDrawable(Context context, int color, boolean inverted) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context);
}
@Override
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
throw new UnsupportedOperationException();
}
@Override
public Drawable asCallCard(Context context) {
Drawable background = new ColorDrawable(backgroundColor);
Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp);
int transparent20 = ContextCompat.getColor(context, R.color.signal_transparent_20);
Drawable gradient = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, new int[]{ Color.TRANSPARENT, transparent20 });
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
public Drawable asCallCard(@NonNull Context context) {
Drawable background = new ColorDrawable(color.colorInt());
Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable80dp));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
int foregroundInset = ViewUtil.dpToPx(24);
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);
return drawable;
@@ -59,12 +57,12 @@ public final class FallbackPhoto80dp implements FallbackContactPhoto {
private @NonNull Drawable buildDrawable(@NonNull Context context) {
Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate();
Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp);
Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient);
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable80dp));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
int foregroundInset = ViewUtil.dpToPx(24);
DrawableCompat.setTint(background, backgroundColor);
DrawableCompat.setTint(background, color.colorInt());
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);

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